Files
immich/mobile/lib/repositories/upload.repository.dart
Claude 1811d42d79 feat: skip local hashing for iCloud assets, use server-computed checksum
Instead of downloading iCloud assets twice (once to hash, once to upload),
skip local hashing for iCloud-only assets and let the server return its
computed SHA1 checksum in the upload response. The mobile app stores this
checksum locally to prevent re-uploads.

Changes:
- Server returns checksum in upload response (created + duplicate)
- iOS native layer tags iCloud-only assets with ICLOUD_ONLY error
- Hash service skips iCloud assets (allowNetworkAccess: false)
- Upload result carries server checksum back to mobile
- Foreground/background upload services store server checksum
- Backup candidates include unhashed assets (onlyHashed: false)

https://claude.ai/code/session_01LEs74WpkJ1gWcJrFBsp1i2
2026-03-17 18:52:32 +00:00

213 lines
7.0 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:logging/logging.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/utils/debug_print.dart';
final uploadRepositoryProvider = Provider((ref) => UploadRepository());
class UploadRepository {
final Logger logger = Logger('UploadRepository');
void Function(TaskStatusUpdate)? onUploadStatus;
void Function(TaskProgressUpdate)? onTaskProgress;
UploadRepository() {
FileDownloader().registerCallbacks(
group: kBackupGroup,
taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
FileDownloader().registerCallbacks(
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) {
return FileDownloader().enqueue(task);
}
Future<List<bool>> enqueueBackgroundAll(List<UploadTask> tasks) {
return FileDownloader().enqueueAll(tasks);
}
Future<void> deleteDatabaseRecords(String group) {
return FileDownloader().database.deleteAllRecords(group: group);
}
Future<bool> cancelAll(String group) {
return FileDownloader().cancelAll(group: group);
}
Future<int> reset(String group) {
return FileDownloader().reset(group: group);
}
/// Get a list of tasks that are ENQUEUED or RUNNING
Future<List<Task>> getActiveTasks(String group) {
return FileDownloader().allTasks(group: group);
}
Future<void> 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({
required File file,
required String originalFileName,
required Map<String, String> fields,
required Completer<void>? cancelToken,
void Function(int bytes, int totalBytes)? onProgress,
required String logContext,
}) async {
final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
final baseRequest = ProgressMultipartRequest(
'POST',
Uri.parse('$savedEndpoint/assets'),
abortTrigger: cancelToken?.future,
onProgress: onProgress,
);
try {
final fileStream = file.openRead();
final assetRawUploadData = MultipartFile("assetData", fileStream, file.lengthSync(), filename: originalFileName);
baseRequest.fields.addAll(fields);
baseRequest.files.add(assetRawUploadData);
final response = await NetworkRepository.client.send(baseRequest);
final responseBodyString = await response.stream.bytesToString();
if (![200, 201].contains(response.statusCode)) {
String? errorMessage;
if (response.statusCode == 413) {
errorMessage = 'Error(413) File is too large to upload';
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,
checksum: responseBody['checksum'] as String?,
);
} catch (e) {
return UploadResult.error(errorMessage: 'Failed to parse server response');
}
} on RequestAbortedException {
logger.warning("Upload $logContext was cancelled");
return UploadResult.cancelled();
} catch (error, stackTrace) {
logger.warning("Error uploading $logContext: ${error.toString()}: $stackTrace");
return UploadResult.error(errorMessage: error.toString());
}
}
}
class ProgressMultipartRequest extends MultipartRequest with Abortable {
ProgressMultipartRequest(super.method, super.url, {this.abortTrigger, this.onProgress});
@override
final Future<void>? abortTrigger;
final void Function(int bytes, int totalBytes)? onProgress;
@override
ByteStream finalize() {
final byteStream = super.finalize();
if (onProgress == null) return byteStream;
final total = contentLength;
var bytes = 0;
final stream = byteStream.transform(
StreamTransformer.fromHandlers(
handleData: (List<int> data, EventSink<List<int>> sink) {
bytes += data.length;
onProgress!(bytes, total);
sink.add(data);
},
),
);
return ByteStream(stream);
}
}
class UploadResult {
final bool isSuccess;
final bool isCancelled;
final String? remoteAssetId;
final String? checksum;
final String? errorMessage;
final int? statusCode;
const UploadResult({
required this.isSuccess,
required this.isCancelled,
this.remoteAssetId,
this.checksum,
this.errorMessage,
this.statusCode,
});
factory UploadResult.success({required String remoteAssetId, String? checksum}) {
return UploadResult(isSuccess: true, isCancelled: false, remoteAssetId: remoteAssetId, checksum: checksum);
}
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);
}
}