import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.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/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/services/background_upload.service.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/utils/upload_speed_calculator.dart'; import 'package:logging/logging.dart'; part 'drift_backup.provider.freezed.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)'; } @freezed abstract class DriftUploadStatus with _$DriftUploadStatus { const factory DriftUploadStatus({ required String taskId, required String filename, required double progress, required int fileSize, required String networkSpeedAsString, bool? isFailed, String? error, }) = _DriftUploadStatus; } enum BackupError { none, syncFailed } @freezed abstract class DriftBackupState with _$DriftBackupState { const DriftBackupState._(); const factory DriftBackupState({ required int totalCount, required int backupCount, required int remainderCount, required int processingCount, required bool isSyncing, @Default(BackupError.none) BackupError error, required Map uploadItems, @Default({}) Map iCloudDownloadProgress, }) = _DriftBackupState; int get errorCount => uploadItems.values.where((item) => item.isFailed == true).length; } 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; Completer? _cancelToken; 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) { if (!mounted) { _logger.warning("Skip updateError: notifier disposed"); return; } state = state.copyWith(error: error); } void updateSyncing(bool isSyncing) { state = state.copyWith(isSyncing: isSyncing); } Future startForegroundBackup(String userId) async { // Cancel any existing backup before starting a new one if (_cancelToken != null) { stopForegroundBackup(reason: "restarting the backup"); } state = state.copyWith(error: BackupError.none); // A pause during the recount below nulls _cancelToken, so the run keeps its own reference. final cancelToken = Completer(); _cancelToken = cancelToken; // Re-baseline the counters against the same DB read that feeds this run's candidate list, // otherwise a resume counts duplicate successes against the old baseline (#26215). await getBackupStatus(userId); return _foregroundUploadService.uploadCandidates( userId, cancelToken, callbacks: UploadCallbacks( onProgress: _handleForegroundBackupProgress, onSuccess: _handleForegroundBackupSuccess, onError: _handleForegroundBackupError, onICloudProgress: _handleICloudProgress, ), ); } void stopForegroundBackup({required String reason}) { if (_cancelToken != null) { _logger.info("Foreground backup cancelled: $reason"); } _cancelToken?.complete(); _cancelToken = null; _uploadSpeedManager.clear(); state = state.copyWith(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 (_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) { if (!mounted) { _logger.warning("Skip _handleForegroundBackupSuccess: notifier disposed"); return; } 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) { 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(driftProvider) .localAssetRepository .getSourceAlbums(assetId, backupSelection: BackupSelection.selected); });