* fix(mobile): hide server addresses in upload error logs * fix(mobile): remove duplicate upload failure logs (cherry picked from commit 211773f778ca24ac33e64d46e50b3ab46702c851)
295 lines
9.4 KiB
Dart
295 lines
9.4 KiB
Dart
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 'backup.provider.freezed.dart';
|
|
|
|
@freezed
|
|
abstract class UploadStatus with _$UploadStatus {
|
|
const factory UploadStatus({
|
|
required String taskId,
|
|
required String filename,
|
|
required double progress,
|
|
required int fileSize,
|
|
required String networkSpeedAsString,
|
|
bool? isFailed,
|
|
String? error,
|
|
}) = _UploadStatus;
|
|
}
|
|
|
|
enum BackupError { none, syncFailed }
|
|
|
|
@freezed
|
|
abstract class BackupState with _$BackupState {
|
|
const BackupState._();
|
|
|
|
const factory BackupState({
|
|
required int totalCount,
|
|
required int backupCount,
|
|
required int remainderCount,
|
|
required int processingCount,
|
|
required bool isSyncing,
|
|
@Default(BackupError.none) BackupError error,
|
|
required Map<String, UploadStatus> uploadItems,
|
|
@Default({}) Map<String, double> iCloudDownloadProgress,
|
|
}) = _BackupState;
|
|
|
|
int get errorCount => uploadItems.values.where((item) => item.isFailed == true).length;
|
|
}
|
|
|
|
final backupProvider = StateNotifierProvider<BackupNotifier, BackupState>((ref) {
|
|
return BackupNotifier(
|
|
ref.watch(foregroundUploadServiceProvider),
|
|
ref.watch(backgroundUploadServiceProvider),
|
|
UploadSpeedManager(),
|
|
);
|
|
});
|
|
|
|
class BackupNotifier extends StateNotifier<BackupState> {
|
|
BackupNotifier(this._foregroundUploadService, this._backgroundUploadService, this._uploadSpeedManager)
|
|
: super(
|
|
const BackupState(
|
|
totalCount: 0,
|
|
backupCount: 0,
|
|
remainderCount: 0,
|
|
processingCount: 0,
|
|
isSyncing: false,
|
|
uploadItems: {},
|
|
error: BackupError.none,
|
|
),
|
|
);
|
|
|
|
final ForegroundUploadService _foregroundUploadService;
|
|
final BackgroundUploadService _backgroundUploadService;
|
|
final UploadSpeedManager _uploadSpeedManager;
|
|
Completer<void>? _cancelToken;
|
|
|
|
final _logger = Logger("BackupNotifier");
|
|
|
|
/// 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<String, UploadStatus>.from(state.uploadItems);
|
|
updatedItems.remove(taskId);
|
|
state = state.copyWith(uploadItems: updatedItems);
|
|
}
|
|
}
|
|
|
|
Future<void> 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<void> 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<void>();
|
|
_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<String, double>.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: UploadStatus(
|
|
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) {
|
|
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: UploadStatus(
|
|
taskId: localAssetId,
|
|
filename: 'Unknown',
|
|
progress: 0,
|
|
fileSize: 0,
|
|
networkSpeedAsString: '',
|
|
isFailed: true,
|
|
error: errorMessage,
|
|
),
|
|
},
|
|
);
|
|
}
|
|
|
|
_uploadSpeedManager.removeTask(localAssetId);
|
|
}
|
|
|
|
Future<void> startBackupWithURLSession(String userId, Future<bool> remoteSync) 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) {
|
|
if (!await remoteSync) {
|
|
_logger.warning("Remote sync did not complete successfully, skipping new upload");
|
|
return;
|
|
}
|
|
_logger.info("No pending tasks, starting new upload");
|
|
return _backgroundUploadService.uploadBackupCandidates(userId);
|
|
}
|
|
|
|
_logger.info("Resuming upload ${tasks.length} assets");
|
|
return _backgroundUploadService.resume();
|
|
}
|
|
}
|
|
|
|
final backupCandidateProvider = FutureProvider.autoDispose<List<LocalAsset>>((ref) {
|
|
final user = ref.watch(currentUserProvider);
|
|
if (user == null) {
|
|
return [];
|
|
}
|
|
|
|
return ref.read(foregroundUploadServiceProvider).getBackupCandidates(user.id, onlyHashed: false);
|
|
});
|
|
|
|
final candidateBackupAlbumInfoProvider = FutureProvider.autoDispose.family<List<LocalAlbum>, String>((ref, assetId) {
|
|
return ref
|
|
.read(driftProvider)
|
|
.localAssetRepository
|
|
.getSourceAlbums(assetId, backupSelection: BackupSelection.selected);
|
|
});
|