Compare commits

...

3 Commits

Author SHA1 Message Date
mertalev 4423a8f8a4 pass cancel token to icloud download 2026-05-28 01:57:39 -04:00
mertalev 77fd2ba919 more cleanup 2026-05-28 00:44:11 -04:00
mertalev 1318dafdc4 use pattern matching 2026-05-27 20:02:51 -04:00
9 changed files with 171 additions and 317 deletions
@@ -234,24 +234,13 @@ class RemoteAlbumService {
final pendingAdds = <Future<void>>[]; final pendingAdds = <Future<void>>[];
final localById = {for (final a in localAssets) a.id: a}; final localById = {for (final a in localAssets) a.id: a};
final UploadCallbacks(:onProgress, :onSuccess, :onError, :onICloudProgress) = userCallbacks;
final wrappedCallbacks = UploadCallbacks( final wrappedCallbacks = UploadCallbacks(
onProgress: (localId, filename, bytes, totalBytes) => _runUploadCallback( onProgress: onProgress,
'Upload progress callback failed for $localId', onICloudProgress: onICloudProgress,
() => userCallbacks.onProgress?.call(localId, filename, bytes, totalBytes), onError: onError,
),
onICloudProgress: (localId, progress) => _runUploadCallback(
'iCloud progress callback failed for $localId',
() => userCallbacks.onICloudProgress?.call(localId, progress),
),
onError: (localId, errorMessage) => _runUploadCallback(
'Upload error callback failed for $localId',
() => userCallbacks.onError?.call(localId, errorMessage),
),
onSuccess: (localId, remoteId) { onSuccess: (localId, remoteId) {
_runUploadCallback( onSuccess?.call(localId, remoteId);
'Upload success callback failed for $localId',
() => userCallbacks.onSuccess?.call(localId, remoteId),
);
final source = localById[localId]; final source = localById[localId];
if (source == null) { if (source == null) {
_logger.warning('Upload success for $localId but source LocalAsset missing; skipping album link'); _logger.warning('Upload success for $localId but source LocalAsset missing; skipping album link');
@@ -259,29 +248,22 @@ class RemoteAlbumService {
} }
pendingAdds.add( pendingAdds.add(
_linkUploadedAssetToAlbum(albumId, remoteId, uploader, source) _linkUploadedAssetToAlbum(albumId, remoteId, uploader, source)
.then<void>((added) { .then<void>((added) => addedCount += added)
addedCount += added; .onError(
}) (error, stack) =>
.catchError((Object error, StackTrace stack) { _logger.warning('Failed to add uploaded asset $remoteId to album $albumId', error, stack),
_logger.warning('Failed to add uploaded asset $remoteId to album $albumId', error, stack); ),
}),
); );
}, },
); );
await _uploadService.uploadManual(localAssets, callbacks: wrappedCallbacks); await _uploadService.uploadManual(localAssets, cancelToken: null, callbacks: wrappedCallbacks);
await Future.wait(pendingAdds); await Future.wait(pendingAdds);
return addedCount; return addedCount;
} }
void _runUploadCallback(String message, void Function() callback) { // TODO: this is a poorly designed flow; adding a "stub" just to satisfy FK constraints is hacky,
try { // it goes out of its way to insert one at a time, and it swallows errors that should be surfaced to the user.
callback();
} catch (error, stack) {
_logger.warning(message, error, stack);
}
}
/// Links a freshly-uploaded asset to an album, ensuring the local DB /// Links a freshly-uploaded asset to an album, ensuring the local DB
/// reflects the change without waiting for the next sync. We call the API /// reflects the change without waiting for the next sync. We call the API
/// (server is the source of truth), then upsert a placeholder /// (server is the source of truth), then upsert a placeholder
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -5,66 +6,54 @@ import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
typedef OnProgress = void Function(String id, double progress);
class StorageRepository { class StorageRepository {
final log = Logger('StorageRepository'); static final log = Logger('StorageRepository');
StorageRepository(); const StorageRepository();
Future<File?> getFileForAsset(String assetId) async { Future<File?> getAssetFile(String assetId, {OnProgress? onProgress, Completer<void>? cancelToken}) {
File? file; return _getFileForAsset(assetId, isMotion: false, onProgress: onProgress, cancelToken: cancelToken);
final log = Logger('StorageRepository');
try {
final entity = await AssetEntity.fromId(assetId);
file = await entity?.originFile;
if (file == null) {
log.warning("Cannot get file for asset $assetId");
return null;
}
final exists = await file.exists();
if (!exists) {
log.warning("File for asset $assetId does not exist");
return null;
}
} catch (error, stackTrace) {
log.warning("Error getting file for asset $assetId", error, stackTrace);
}
return file;
} }
Future<File?> getMotionFileForAsset(LocalAsset asset) async { Future<File?> getMotionFile(String assetId, {OnProgress? onProgress, Completer<void>? cancelToken}) {
File? file; return _getFileForAsset(assetId, isMotion: true, onProgress: onProgress, cancelToken: cancelToken);
final log = Logger('StorageRepository'); }
Future<File?> _getFileForAsset(
String assetId, {
bool isMotion = false,
OnProgress? onProgress,
Completer<void>? cancelToken,
}) async {
final entity = await AssetEntity.fromId(assetId);
if (entity == null) {
log.warning("Cannot get AssetEntity for asset $assetId");
return null;
}
PMProgressHandler? progressHandler;
StreamSubscription<PMProgressState>? progressSubscription;
PMCancelToken? pmCancelToken;
if (cancelToken != null) {
progressHandler = PMProgressHandler();
progressSubscription = progressHandler.stream.listen((event) => onProgress?.call(assetId, event.progress));
pmCancelToken = PMCancelToken();
unawaited(cancelToken.future.then((_) => pmCancelToken!.cancelRequest()));
}
try { try {
final entity = await AssetEntity.fromId(asset.id); return await entity.loadFile(withSubtype: isMotion, progressHandler: progressHandler, cancelToken: pmCancelToken);
file = await entity?.originFileWithSubtype;
if (file == null) {
log.warning(
"Cannot get motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
);
return null;
}
final exists = await file.exists();
if (!exists) {
log.warning("Motion file for asset ${asset.id} does not exist");
return null;
}
} catch (error, stackTrace) { } catch (error, stackTrace) {
log.warning( log.warning("Error loading file for asset $assetId", error, stackTrace);
"Error getting motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}", return null;
error, } finally {
stackTrace, unawaited(progressSubscription?.cancel());
);
} }
return file;
} }
Future<AssetEntity?> getAssetEntityForAsset(LocalAsset asset) async { Future<AssetEntity?> getAssetEntityForAsset(LocalAsset asset) async {
final log = Logger('StorageRepository');
AssetEntity? entity; AssetEntity? entity;
try { try {
@@ -99,39 +88,7 @@ class StorageRepository {
} }
} }
Future<File?> loadFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async {
try {
final entity = await AssetEntity.fromId(assetId);
if (entity == null) {
log.warning("Cannot get AssetEntity for asset $assetId");
return null;
}
return await entity.loadFile(progressHandler: progressHandler);
} catch (error, stackTrace) {
log.warning("Error loading file from cloud for asset $assetId", error, stackTrace);
return null;
}
}
Future<File?> loadMotionFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async {
try {
final entity = await AssetEntity.fromId(assetId);
if (entity == null) {
log.warning("Cannot get AssetEntity for asset $assetId");
return null;
}
return await entity.loadFile(withSubtype: true, progressHandler: progressHandler);
} catch (error, stackTrace) {
log.warning("Error loading motion file from cloud for asset $assetId", error, stackTrace);
return null;
}
}
Future<void> clearCache() async { Future<void> clearCache() async {
final log = Logger('StorageRepository');
try { try {
await PhotoManager.clearFileCache(); await PhotoManager.clearFileCache();
} catch (error, stackTrace) { } catch (error, stackTrace) {
@@ -6,13 +6,13 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart'; import 'package:native_video_player/native_video_player.dart';
@@ -108,7 +108,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
try { try {
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) { if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!; final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
final file = await StorageRepository().getFileForAsset(id); final file = await ref.read(storageRepositoryProvider).getAssetFile(id);
if (!mounted) { if (!mounted) {
return null; return null;
} }
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
@@ -273,7 +274,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
onProgress: _handleForegroundBackupProgress, onProgress: _handleForegroundBackupProgress,
onSuccess: _handleForegroundBackupSuccess, onSuccess: _handleForegroundBackupSuccess,
onError: _handleForegroundBackupError, onError: _handleForegroundBackupError,
onICloudProgress: _handleICloudProgress, onICloudProgress: CurrentPlatform.isIOS ? _handleICloudProgress : null,
), ),
); );
} }
@@ -282,7 +283,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
_cancelToken?.complete(); _cancelToken?.complete();
_cancelToken = null; _cancelToken = null;
_uploadSpeedManager.clear(); _uploadSpeedManager.clear();
state = state.copyWith(uploadItems: {}, iCloudDownloadProgress: {}); state = state.copyWith(uploadItems: const {}, iCloudDownloadProgress: const {});
} }
void _handleICloudProgress(String localAssetId, double progress) { void _handleICloudProgress(String localAssetId, double progress) {
@@ -1,4 +1,4 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
final storageRepositoryProvider = Provider<StorageRepository>((ref) => StorageRepository()); final storageRepositoryProvider = Provider<StorageRepository>((ref) => const StorageRepository());
+44 -92
View File
@@ -10,7 +10,6 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:immich_mobile/utils/debug_print.dart';
final uploadRepositoryProvider = Provider((ref) => UploadRepository()); final uploadRepositoryProvider = Provider((ref) => UploadRepository());
@@ -20,21 +19,14 @@ class UploadRepository {
void Function(TaskProgressUpdate)? onTaskProgress; void Function(TaskProgressUpdate)? onTaskProgress;
UploadRepository() { UploadRepository() {
FileDownloader().registerCallbacks( final downloader = FileDownloader();
group: kBackupGroup, for (final group in const [kBackupGroup, kBackupLivePhotoGroup, kManualUploadGroup]) {
taskStatusCallback: (update) => onUploadStatus?.call(update), downloader.registerCallbacks(
taskProgressCallback: (update) => onTaskProgress?.call(update), group: group,
); taskStatusCallback: onUploadStatus,
FileDownloader().registerCallbacks( taskProgressCallback: onTaskProgress,
group: kBackupLivePhotoGroup, );
taskStatusCallback: (update) => onUploadStatus?.call(update), }
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
FileDownloader().registerCallbacks(
group: kManualUploadGroup,
taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
} }
Future<void> enqueueBackground(UploadTask task) { Future<void> enqueueBackground(UploadTask task) {
@@ -66,28 +58,6 @@ class UploadRepository {
return FileDownloader().start(); return FileDownloader().start();
} }
Future<void> getUploadInfo() async {
final [enqueuedTasks, runningTasks, canceledTasks, waitingTasks, pausedTasks] = await Future.wait([
FileDownloader().database.allRecordsWithStatus(TaskStatus.enqueued, group: kBackupGroup),
FileDownloader().database.allRecordsWithStatus(TaskStatus.running, group: kBackupGroup),
FileDownloader().database.allRecordsWithStatus(TaskStatus.canceled, group: kBackupGroup),
FileDownloader().database.allRecordsWithStatus(TaskStatus.waitingToRetry, group: kBackupGroup),
FileDownloader().database.allRecordsWithStatus(TaskStatus.paused, group: kBackupGroup),
]);
dPrint(
() =>
"""
Upload Info:
Enqueued: ${enqueuedTasks.length}
Running: ${runningTasks.length}
Canceled: ${canceledTasks.length}
Waiting: ${waitingTasks.length}
Paused: ${pausedTasks.length}
""",
);
}
Future<UploadResult> uploadFile({ Future<UploadResult> uploadFile({
required File file, required File file,
required String originalFileName, required String originalFileName,
@@ -111,41 +81,30 @@ class UploadRepository {
baseRequest.fields.addAll(fields); baseRequest.fields.addAll(fields);
baseRequest.files.add(assetRawUploadData); baseRequest.files.add(assetRawUploadData);
final response = await NetworkRepository.client.send(baseRequest); final StreamedResponse(:statusCode, :stream) = await NetworkRepository.client.send(baseRequest);
final responseBodyString = await response.stream.bytesToString(); final responseBodyString = await stream.bytesToString();
if (![200, 201].contains(response.statusCode)) { return switch ((statusCode, _tryJsonDecode(responseBodyString))) {
String? errorMessage; (200 || 201, {'id': String id}) => UploadSuccess(remoteAssetId: id),
(413, _) => const UploadError(statusCode: 413, message: 'File is too large to upload'),
if (response.statusCode == 413) { (_, {'message': String message}) => UploadError(statusCode: statusCode, message: message),
errorMessage = 'Error(413) File is too large to upload'; _ => UploadError(statusCode: statusCode, message: 'Upload failed with status $statusCode'),
return UploadResult.error(statusCode: response.statusCode, errorMessage: errorMessage); };
}
try {
final error = jsonDecode(responseBodyString);
errorMessage = error['message'] ?? error['error'];
} catch (_) {
errorMessage = responseBodyString.isNotEmpty
? responseBodyString
: 'Upload failed with status ${response.statusCode}';
}
return UploadResult.error(statusCode: response.statusCode, errorMessage: errorMessage);
}
try {
final responseBody = jsonDecode(responseBodyString);
return UploadResult.success(remoteAssetId: responseBody['id'] as String);
} catch (e) {
return UploadResult.error(errorMessage: 'Failed to parse server response');
}
} on RequestAbortedException { } on RequestAbortedException {
logger.warning("Upload $logContext was cancelled"); logger.warning("Upload $logContext was cancelled");
return UploadResult.cancelled(); return const UploadCancelled();
} catch (error, stackTrace) { } catch (error, stackTrace) {
logger.warning("Error uploading $logContext: ${error.toString()}: $stackTrace"); logger.warning("Error uploading $logContext: ${error.toString()}: $stackTrace");
return UploadResult.error(errorMessage: error.toString()); return UploadError(message: error.toString());
}
}
@pragma('vm:prefer-inline')
Map? _tryJsonDecode(String s) {
try {
return (jsonDecode(s) as Map);
} catch (_) {
return null;
} }
} }
} }
@@ -180,30 +139,23 @@ class ProgressMultipartRequest extends MultipartRequest with Abortable {
} }
} }
class UploadResult { sealed class UploadResult {
final bool isSuccess; const UploadResult();
final bool isCancelled; }
final String? remoteAssetId;
final String? errorMessage; final class UploadSuccess extends UploadResult {
final String remoteAssetId;
const UploadSuccess({required this.remoteAssetId});
}
final class UploadError extends UploadResult {
final String message;
final int? statusCode; final int? statusCode;
const UploadResult({ const UploadError({required this.message, this.statusCode});
required this.isSuccess, }
required this.isCancelled,
this.remoteAssetId, final class UploadCancelled extends UploadResult {
this.errorMessage, const UploadCancelled();
this.statusCode,
});
factory UploadResult.success({required String remoteAssetId}) {
return UploadResult(isSuccess: true, isCancelled: false, remoteAssetId: remoteAssetId);
}
factory UploadResult.error({String? errorMessage, int? statusCode}) {
return UploadResult(isSuccess: false, isCancelled: false, errorMessage: errorMessage, statusCode: statusCode);
}
factory UploadResult.cancelled() {
return const UploadResult(isSuccess: false, isCancelled: true);
}
} }
@@ -266,8 +266,6 @@ class BackgroundUploadService {
return null; return null;
} }
File? file;
/// iOS LivePhoto has two files: a photo and a video. /// iOS LivePhoto has two files: a photo and a video.
/// They are uploaded separately, with video file being upload first, then returned with the assetId /// They are uploaded separately, with video file being upload first, then returned with the assetId
/// The assetId is then used as a metadata for the photo file upload task. /// The assetId is then used as a metadata for the photo file upload task.
@@ -278,11 +276,9 @@ class BackgroundUploadService {
/// The cancel operation will only cancel the video group (normal group), the photo group will not /// The cancel operation will only cancel the video group (normal group), the photo group will not
/// be touched, as the video file is already uploaded. /// be touched, as the video file is already uploaded.
if (entity.isLivePhoto) { final file = await (entity.isLivePhoto
file = await _storageRepository.getMotionFileForAsset(asset); ? _storageRepository.getMotionFile(asset.id)
} else { : _storageRepository.getAssetFile(asset.id));
file = await _storageRepository.getFileForAsset(asset.id);
}
if (file == null) { if (file == null) {
_logger.warning("Failed to get file for asset ${asset.id} - ${asset.name}"); _logger.warning("Failed to get file for asset ${asset.id} - ${asset.name}");
@@ -330,7 +326,7 @@ class BackgroundUploadService {
return null; return null;
} }
final file = await _storageRepository.getFileForAsset(asset.id); final file = await _storageRepository.getAssetFile(asset.id);
if (file == null) { if (file == null) {
return null; return null;
} }
@@ -20,7 +20,6 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart'; import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
/// Callbacks for upload progress and status updates /// Callbacks for upload progress and status updates
class UploadCallbacks { class UploadCallbacks {
@@ -99,7 +98,7 @@ class ForegroundUploadService {
final requireWifi = _shouldRequireWiFi(asset); final requireWifi = _shouldRequireWiFi(asset);
return requireWifi && !hasWifi; return requireWifi && !hasWifi;
}, },
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks), processItem: (asset) => _uploadSingleAsset(asset, cancelToken: cancelToken, callbacks: callbacks),
); );
} }
} }
@@ -125,14 +124,14 @@ class ForegroundUploadService {
continue; continue;
} }
await _uploadSingleAsset(asset, cancelToken, callbacks: callbacks); await _uploadSingleAsset(asset, cancelToken: cancelToken, callbacks: callbacks);
} }
} }
/// Manually upload picked local assets /// Manually upload picked local assets
Future<void> uploadManual( Future<void> uploadManual(
List<LocalAsset> localAssets, { List<LocalAsset> localAssets, {
Completer<void>? cancelToken, required Completer<void>? cancelToken,
UploadCallbacks callbacks = const UploadCallbacks(), UploadCallbacks callbacks = const UploadCallbacks(),
}) async { }) async {
if (localAssets.isEmpty) { if (localAssets.isEmpty) {
@@ -142,7 +141,7 @@ class ForegroundUploadService {
await _executeWithWorkerPool<LocalAsset>( await _executeWithWorkerPool<LocalAsset>(
items: localAssets, items: localAssets,
cancelToken: cancelToken, cancelToken: cancelToken,
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks), processItem: (asset) => _uploadSingleAsset(asset, cancelToken: cancelToken, callbacks: callbacks),
); );
} }
@@ -170,11 +169,11 @@ class ForegroundUploadService {
onProgress: (bytes, totalBytes) => onProgress?.call(fileId, bytes, totalBytes), onProgress: (bytes, totalBytes) => onProgress?.call(fileId, bytes, totalBytes),
); );
if (result.isSuccess) { return switch (result) {
onSuccess?.call(fileId); UploadSuccess() => onSuccess?.call(fileId),
} else if (!result.isCancelled && result.errorMessage != null) { UploadError(:final message) => onError?.call(fileId, message),
onError?.call(fileId, result.errorMessage!); UploadCancelled() => null,
} };
}, },
); );
} }
@@ -216,7 +215,7 @@ class ForegroundUploadService {
final item = items[index]; final item = items[index];
if (shouldSkip?.call(item) ?? false) { if (shouldSkip != null && shouldSkip(item)) {
continue; continue;
} }
@@ -233,78 +232,48 @@ class ForegroundUploadService {
} }
Future<void> _uploadSingleAsset( Future<void> _uploadSingleAsset(
LocalAsset asset, LocalAsset asset, {
Completer<void>? cancelToken, { required Completer<void>? cancelToken,
required UploadCallbacks callbacks, required UploadCallbacks callbacks,
}) async { }) async {
File? file; final UploadCallbacks(:onProgress, :onSuccess, :onError, :onICloudProgress) = callbacks;
File? assetFile;
File? livePhotoFile; File? livePhotoFile;
try { try {
final entity = await _storageRepository.getAssetEntityForAsset(asset); final entity = await _storageRepository.getAssetEntityForAsset(asset);
if (entity == null) { if (entity == null) {
callbacks.onError?.call( onError?.call(
asset.localId!, asset.localId!,
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(), CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
); );
return; return;
} }
final isAvailableLocally = await _storageRepository.isAssetAvailableLocally(asset.id); File? file;
if (entity.isLivePhoto) {
if (!isAvailableLocally && CurrentPlatform.isIOS) { file = await _storageRepository.getMotionFile(asset.id, cancelToken: cancelToken, onProgress: onICloudProgress);
_logger.info("Loading iCloud asset ${asset.id} - ${asset.name}");
// Create progress handler for iCloud download
PMProgressHandler? progressHandler;
StreamSubscription? progressSubscription;
progressHandler = PMProgressHandler();
progressSubscription = progressHandler.stream.listen((event) {
callbacks.onICloudProgress?.call(asset.localId!, event.progress);
});
try {
file = await _storageRepository.loadFileFromCloud(asset.id, progressHandler: progressHandler);
if (entity.isLivePhoto) {
livePhotoFile = await _storageRepository.loadMotionFileFromCloud(
asset.id,
progressHandler: progressHandler,
);
}
} finally {
await progressSubscription.cancel();
}
} else {
// Get files locally
file = await _storageRepository.getFileForAsset(asset.id);
if (file == null) { if (file == null) {
_logger.warning("Failed to get file ${asset.id} - ${asset.name}"); _logger.warning("Failed to obtain motion part of the livePhoto - ${asset.name}");
callbacks.onError?.call( onError?.call(
asset.localId!, asset.localId!,
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(), CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
); );
return; return;
} }
livePhotoFile = file;
// For live photos, get the motion video file
if (entity.isLivePhoto) {
livePhotoFile = await _storageRepository.getMotionFileForAsset(asset);
if (livePhotoFile == null) {
_logger.warning("Failed to obtain motion part of the livePhoto - ${asset.name}");
callbacks.onError?.call(
asset.localId!,
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
);
}
}
} }
file = await _storageRepository.getAssetFile(asset.id, cancelToken: cancelToken, onProgress: onICloudProgress);
if (file == null) { if (file == null) {
_logger.warning("Failed to obtain file from iCloud for asset ${asset.id} - ${asset.name}"); _logger.warning("Failed to get file ${asset.id} - ${asset.name}");
callbacks.onError?.call(asset.localId!, "asset_not_found_on_icloud".t()); onError?.call(
asset.localId!,
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
);
return; return;
} }
assetFile = file;
String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name; String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
@@ -330,11 +299,9 @@ class ForegroundUploadService {
}; };
// Upload live photo video first if available // Upload live photo video first if available
String? livePhotoVideoId;
if (entity.isLivePhoto && livePhotoFile != null) { if (entity.isLivePhoto && livePhotoFile != null) {
final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoFile.path)); final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoFile.path));
final onProgress = callbacks.onProgress;
final livePhotoResult = await _uploadRepository.uploadFile( final livePhotoResult = await _uploadRepository.uploadFile(
file: livePhotoFile, file: livePhotoFile,
originalFileName: livePhotoTitle, originalFileName: livePhotoTitle,
@@ -346,15 +313,16 @@ class ForegroundUploadService {
logContext: 'livePhotoVideo[${asset.localId}]', logContext: 'livePhotoVideo[${asset.localId}]',
); );
if (livePhotoResult.isSuccess && livePhotoResult.remoteAssetId != null) { switch (livePhotoResult) {
livePhotoVideoId = livePhotoResult.remoteAssetId; case UploadSuccess(:final remoteAssetId):
fields['livePhotoVideoId'] = remoteAssetId;
case UploadError(:final message):
onError?.call(asset.localId!, "Failed to upload live photo video: $message");
return;
case UploadCancelled():
} }
} }
if (livePhotoVideoId != null) {
fields['livePhotoVideoId'] = livePhotoVideoId;
}
// Add cloudId metadata only to the still image, not the motion video, becasue when the sync id happens, the motion video can get associated with the wrong still image. // Add cloudId metadata only to the still image, not the motion video, becasue when the sync id happens, the motion video can get associated with the wrong still image.
if (CurrentPlatform.isIOS && asset.cloudId != null) { if (CurrentPlatform.isIOS && asset.cloudId != null) {
fields['metadata'] = jsonEncode([ fields['metadata'] = jsonEncode([
@@ -371,7 +339,6 @@ class ForegroundUploadService {
]); ]);
} }
final onProgress = callbacks.onProgress;
final result = await _uploadRepository.uploadFile( final result = await _uploadRepository.uploadFile(
file: file, file: file,
originalFileName: originalFileName, originalFileName: originalFileName,
@@ -383,34 +350,33 @@ class ForegroundUploadService {
logContext: 'asset[${asset.localId}]', logContext: 'asset[${asset.localId}]',
); );
if (result.isSuccess && result.remoteAssetId != null) { switch (result) {
callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!); case UploadSuccess(:final remoteAssetId):
} else if (result.isCancelled) { onSuccess?.call(asset.localId!, remoteAssetId);
_logger.warning(() => "Backup was cancelled by the user"); case UploadCancelled():
shouldAbortUpload = true;
} else if (result.errorMessage != null) {
_logger.severe(
() =>
"Error(${result.statusCode}) uploading ${asset.localId} | $originalFileName | Created on ${asset.createdAt} | ${result.errorMessage}",
);
callbacks.onError?.call(asset.localId!, result.errorMessage!);
if (result.errorMessage == "Quota has been exceeded!") {
shouldAbortUpload = true; shouldAbortUpload = true;
} _logger.warning("Upload was cancelled by the user for asset ${asset.localId}");
case UploadError(:final message, :final statusCode):
shouldAbortUpload |= message == "Quota has been exceeded!";
_logger.severe(
"Error(${statusCode ?? 'unknown'}) uploading ${asset.localId} | $originalFileName | Created on ${asset.createdAt} | $message",
);
onError?.call(asset.localId!, message);
} }
} catch (error, stackTrace) { } catch (error, stackTrace) {
_logger.severe(() => "Error backup asset: ${error.toString()}", stackTrace); _logger.severe("Asset backup failed", error, stackTrace);
callbacks.onError?.call(asset.localId!, error.toString()); onError?.call(asset.localId!, error.toString());
} finally { } finally {
if (Platform.isIOS) { if (Platform.isIOS) {
try { unawaited(
await file?.delete(); Future.wait([
await livePhotoFile?.delete(); if (assetFile != null) assetFile.delete(),
} catch (error, stackTrace) { if (livePhotoFile != null) livePhotoFile.delete(),
_logger.severe(() => "ERROR deleting file: ${error.toString()}", stackTrace); ]).onError((error, stackTrace) {
} _logger.severe("Post-upload file cleanup failed", error, stackTrace);
return const [];
}),
);
} }
} }
} }
@@ -446,7 +412,7 @@ class ForegroundUploadService {
logContext: 'shareIntent[$deviceAssetId]', logContext: 'shareIntent[$deviceAssetId]',
); );
} catch (e) { } catch (e) {
return UploadResult.error(errorMessage: e.toString()); return UploadError(message: e.toString());
} }
} }
@@ -75,7 +75,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(false); when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile); when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'OriginalPhoto.jpg'); when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'OriginalPhoto.jpg');
final task = await sut.getUploadTask(asset); final task = await sut.getUploadTask(asset);
@@ -92,7 +92,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(false); when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile); when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null); when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null);
final task = await sut.getUploadTask(asset); final task = await sut.getUploadTask(asset);
@@ -109,7 +109,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(true); when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getMotionFileForAsset(asset)).thenAnswer((_) async => mockFile); when(() => mockStorageRepository.getMotionFile(asset.id)).thenAnswer((_) async => mockFile);
when( when(
() => mockAssetMediaRepository.getOriginalFilename(asset.id), () => mockAssetMediaRepository.getOriginalFilename(asset.id),
).thenAnswer((_) async => 'OriginalLivePhoto.HEIC'); ).thenAnswer((_) async => 'OriginalLivePhoto.HEIC');
@@ -130,7 +130,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(true); when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile); when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile);
when( when(
() => mockAssetMediaRepository.getOriginalFilename(asset.id), () => mockAssetMediaRepository.getOriginalFilename(asset.id),
).thenAnswer((_) async => 'OriginalLivePhoto.HEIC'); ).thenAnswer((_) async => 'OriginalLivePhoto.HEIC');
@@ -150,7 +150,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(true); when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile); when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null); when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null);
final task = await sut.getLivePhotoUploadTask(asset, 'video-id-456'); final task = await sut.getLivePhotoUploadTask(asset, 'video-id-456');
@@ -194,7 +194,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(false); when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile); when(() => mockStorageRepository.getAssetFile(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg'); when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
final task = await sutWithV24.getUploadTask(assetWithCloudId); final task = await sutWithV24.getUploadTask(assetWithCloudId);
@@ -243,7 +243,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(false); when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile); when(() => mockStorageRepository.getAssetFile(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg'); when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
final task = await sutAndroid.getUploadTask(assetWithCloudId); final task = await sutAndroid.getUploadTask(assetWithCloudId);
@@ -281,7 +281,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(false); when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithoutCloudId)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getAssetEntityForAsset(assetWithoutCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(assetWithoutCloudId.id)).thenAnswer((_) async => mockFile); when(() => mockStorageRepository.getAssetFile(assetWithoutCloudId.id)).thenAnswer((_) async => mockFile);
when( when(
() => mockAssetMediaRepository.getOriginalFilename(assetWithoutCloudId.id), () => mockAssetMediaRepository.getOriginalFilename(assetWithoutCloudId.id),
).thenAnswer((_) async => 'test.jpg'); ).thenAnswer((_) async => 'test.jpg');
@@ -323,7 +323,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(true); when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile); when(() => mockStorageRepository.getAssetFile(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when( when(
() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id), () => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id),
).thenAnswer((_) async => 'livephoto.heic'); ).thenAnswer((_) async => 'livephoto.heic');