import 'dart:async'; import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:logging/logging.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/utils/upload_speed_calculator.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/services/background_upload.service.dart'; class EnqueueStatus { final int enqueueCount; final int totalCount; const EnqueueStatus({required this.enqueueCount, required this.totalCount}); EnqueueStatus copyWith({int? enqueueCount, int? totalCount}) { return EnqueueStatus(enqueueCount: enqueueCount ?? this.enqueueCount, totalCount: totalCount ?? this.totalCount); } @override String toString() => 'EnqueueStatus(enqueueCount: $enqueueCount, totalCount: $totalCount)'; } class DriftUploadStatus { final String taskId; final String filename; final double progress; final int fileSize; final String networkSpeedAsString; final bool? isFailed; final String? error; const DriftUploadStatus({ required this.taskId, required this.filename, required this.progress, required this.fileSize, required this.networkSpeedAsString, this.isFailed, this.error, }); DriftUploadStatus copyWith({ String? taskId, String? filename, double? progress, int? fileSize, String? networkSpeedAsString, bool? isFailed, String? error, }) { return DriftUploadStatus( taskId: taskId ?? this.taskId, filename: filename ?? this.filename, progress: progress ?? this.progress, fileSize: fileSize ?? this.fileSize, networkSpeedAsString: networkSpeedAsString ?? this.networkSpeedAsString, isFailed: isFailed ?? this.isFailed, error: error ?? this.error, ); } @override String toString() { return 'DriftUploadStatus(taskId: $taskId, filename: $filename, progress: $progress, fileSize: $fileSize, networkSpeedAsString: $networkSpeedAsString, isFailed: $isFailed, error: $error)'; } @override bool operator ==(covariant DriftUploadStatus other) { if (identical(this, other)) return true; return other.taskId == taskId && other.filename == filename && other.progress == progress && other.fileSize == fileSize && other.networkSpeedAsString == networkSpeedAsString && other.isFailed == isFailed && other.error == error; } @override int get hashCode { return taskId.hashCode ^ filename.hashCode ^ progress.hashCode ^ fileSize.hashCode ^ networkSpeedAsString.hashCode ^ isFailed.hashCode ^ error.hashCode; } } enum BackupError { none, syncFailed } class DriftBackupState { final int totalCount; final int backupCount; final int remainderCount; final int processingCount; final bool isSyncing; final BackupError error; final Map uploadItems; final CancellationToken? cancelToken; final Map iCloudDownloadProgress; const DriftBackupState({ required this.totalCount, required this.backupCount, required this.remainderCount, required this.processingCount, required this.isSyncing, this.error = BackupError.none, required this.uploadItems, this.cancelToken, this.iCloudDownloadProgress = const {}, }); DriftBackupState copyWith({ int? totalCount, int? backupCount, int? remainderCount, int? processingCount, bool? isSyncing, BackupError? error, Map? uploadItems, CancellationToken? cancelToken, Map? iCloudDownloadProgress, }) { return DriftBackupState( totalCount: totalCount ?? this.totalCount, backupCount: backupCount ?? this.backupCount, remainderCount: remainderCount ?? this.remainderCount, processingCount: processingCount ?? this.processingCount, isSyncing: isSyncing ?? this.isSyncing, error: error ?? this.error, uploadItems: uploadItems ?? this.uploadItems, cancelToken: cancelToken ?? this.cancelToken, iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress, ); } int get errorCount => uploadItems.values.where((item) => item.isFailed == true).length; @override String toString() { return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, isSyncing: $isSyncing, error: $error, uploadItems: $uploadItems, cancelToken: $cancelToken, iCloudDownloadProgress: $iCloudDownloadProgress)'; } @override bool operator ==(covariant DriftBackupState other) { if (identical(this, other)) return true; final mapEquals = const DeepCollectionEquality().equals; return other.totalCount == totalCount && other.backupCount == backupCount && other.remainderCount == remainderCount && other.processingCount == processingCount && other.isSyncing == isSyncing && other.error == error && mapEquals(other.iCloudDownloadProgress, iCloudDownloadProgress) && mapEquals(other.uploadItems, uploadItems) && other.cancelToken == cancelToken; } @override int get hashCode { return totalCount.hashCode ^ backupCount.hashCode ^ remainderCount.hashCode ^ processingCount.hashCode ^ isSyncing.hashCode ^ error.hashCode ^ uploadItems.hashCode ^ cancelToken.hashCode ^ iCloudDownloadProgress.hashCode; } } final driftBackupProvider = StateNotifierProvider((ref) { return DriftBackupNotifier( ref.watch(foregroundUploadServiceProvider), ref.watch(backgroundUploadServiceProvider), UploadSpeedManager(), ); }); class DriftBackupNotifier extends StateNotifier { DriftBackupNotifier(this._foregroundUploadService, this._backgroundUploadService, this._uploadSpeedManager) : super( const DriftBackupState( totalCount: 0, backupCount: 0, remainderCount: 0, processingCount: 0, isSyncing: false, uploadItems: {}, error: BackupError.none, ), ); final ForegroundUploadService _foregroundUploadService; final BackgroundUploadService _backgroundUploadService; final UploadSpeedManager _uploadSpeedManager; final _logger = Logger("DriftBackupNotifier"); /// Remove upload item from state void _removeUploadItem(String taskId) { if (!mounted) { _logger.warning("Skip _removeUploadItem: notifier disposed"); return; } if (state.uploadItems.containsKey(taskId)) { final updatedItems = Map.from(state.uploadItems); updatedItems.remove(taskId); state = state.copyWith(uploadItems: updatedItems); } } Future getBackupStatus(String userId) async { if (!mounted) { _logger.warning("Skip getBackupStatus (pre-call): notifier disposed"); return; } final counts = await _foregroundUploadService.getBackupCounts(userId); if (!mounted) { _logger.warning("Skip getBackupStatus (post-call): notifier disposed"); return; } state = state.copyWith( totalCount: counts.total, backupCount: counts.total - counts.remainder, remainderCount: counts.remainder, processingCount: counts.processing, ); } void updateError(BackupError error) async { if (!mounted) { _logger.warning("Skip updateError: notifier disposed"); return; } state = state.copyWith(error: error); } void updateSyncing(bool isSyncing) async { state = state.copyWith(isSyncing: isSyncing); } Future startForegroundBackup(String userId) async { // Cancel any existing backup before starting a new one if (state.cancelToken != null) { await stopForegroundBackup(); } state = state.copyWith(error: BackupError.none); final cancelToken = CancellationToken(); state = state.copyWith(cancelToken: cancelToken); return _foregroundUploadService.uploadCandidates( userId, cancelToken, callbacks: UploadCallbacks( onProgress: _handleForegroundBackupProgress, onSuccess: _handleForegroundBackupSuccess, onError: _handleForegroundBackupError, onICloudProgress: _handleICloudProgress, ), ); } Future stopForegroundBackup() async { state.cancelToken?.cancel(); _uploadSpeedManager.clear(); state = state.copyWith(cancelToken: null, uploadItems: {}, iCloudDownloadProgress: {}); } void _handleICloudProgress(String localAssetId, double progress) { state = state.copyWith(iCloudDownloadProgress: {...state.iCloudDownloadProgress, localAssetId: progress}); if (progress >= 1.0) { Future.delayed(const Duration(milliseconds: 250), () { final updatedProgress = Map.from(state.iCloudDownloadProgress); updatedProgress.remove(localAssetId); state = state.copyWith(iCloudDownloadProgress: updatedProgress); }); } } void _handleForegroundBackupProgress(String localAssetId, String filename, int bytes, int totalBytes) { if (state.cancelToken == null) { return; } final progress = totalBytes > 0 ? bytes / totalBytes : 0.0; final networkSpeedAsString = _uploadSpeedManager.updateProgress(localAssetId, bytes, totalBytes); final currentItem = state.uploadItems[localAssetId]; if (currentItem != null) { state = state.copyWith( uploadItems: { ...state.uploadItems, localAssetId: currentItem.copyWith( filename: filename, progress: progress, fileSize: totalBytes, networkSpeedAsString: networkSpeedAsString, ), }, ); } else { state = state.copyWith( uploadItems: { ...state.uploadItems, localAssetId: DriftUploadStatus( taskId: localAssetId, filename: filename, progress: progress, fileSize: totalBytes, networkSpeedAsString: networkSpeedAsString, ), }, ); } } void _handleForegroundBackupSuccess(String localAssetId, String remoteAssetId) { state = state.copyWith(backupCount: state.backupCount + 1, remainderCount: state.remainderCount - 1); _uploadSpeedManager.removeTask(localAssetId); Future.delayed(const Duration(milliseconds: 1000), () { _removeUploadItem(localAssetId); }); } void _handleForegroundBackupError(String localAssetId, String errorMessage) { _logger.severe("Upload failed for $localAssetId: $errorMessage"); final currentItem = state.uploadItems[localAssetId]; if (currentItem != null) { state = state.copyWith( uploadItems: { ...state.uploadItems, localAssetId: currentItem.copyWith(isFailed: true, error: errorMessage), }, ); } else { state = state.copyWith( uploadItems: { ...state.uploadItems, localAssetId: DriftUploadStatus( taskId: localAssetId, filename: 'Unknown', progress: 0, fileSize: 0, networkSpeedAsString: '', isFailed: true, error: errorMessage, ), }, ); } _uploadSpeedManager.removeTask(localAssetId); } Future startBackupWithURLSession(String userId) async { if (!mounted) { _logger.warning("Skip handleBackupResume (pre-call): notifier disposed"); return; } _logger.info("Start background backup sequence"); state = state.copyWith(error: BackupError.none); final tasks = await _backgroundUploadService.getActiveTasks(kBackupGroup); if (!mounted) { _logger.warning("Skip handleBackupResume (post-call): notifier disposed"); return; } _logger.info("Found ${tasks.length} pending tasks"); if (tasks.isEmpty) { _logger.info("No pending tasks, starting new upload"); return _backgroundUploadService.uploadBackupCandidates(userId); } _logger.info("Resuming upload ${tasks.length} assets"); return _backgroundUploadService.resume(); } } final driftBackupCandidateProvider = FutureProvider.autoDispose>((ref) async { final user = ref.watch(currentUserProvider); if (user == null) { return []; } return ref.read(foregroundUploadServiceProvider).getBackupCandidates(user.id, onlyHashed: false); }); final driftCandidateBackupAlbumInfoProvider = FutureProvider.autoDispose.family, String>(( ref, assetId, ) { return ref.read(localAssetRepository).getSourceAlbums(assetId, backupSelection: BackupSelection.selected); });