fix(mobile): keep backup remainder from going negative

a duplicate upload comes back as a 200 and still fires onSuccess, so the
remainder kept decrementing past zero and backup ran over total. floor the
remainder at 0 and derive backup from total so the two stay in sync.
This commit is contained in:
Santo Shakil
2026-06-12 13:50:24 +06:00
parent 296cd40da9
commit c11d8cda02
3 changed files with 111 additions and 2 deletions
@@ -578,7 +578,7 @@ class _PreparingStatusState extends ConsumerState {
final syncStatus = ref.watch(syncStatusProvider);
final remainderCount = ref.watch(driftBackupProvider.select((p) => p.remainderCount));
final processingCount = ref.watch(driftBackupProvider.select((p) => p.processingCount));
final readyForUploadCount = remainderCount - processingCount;
final readyForUploadCount = (remainderCount - processingCount).clamp(0, remainderCount);
ref.listen<int>(driftBackupProvider.select((p) => p.processingCount), (previous, next) {
if (next > 0 && _pollingTimer == null) {
@@ -334,7 +334,11 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
void _handleForegroundBackupSuccess(String localAssetId, String remoteAssetId) {
state = state.copyWith(backupCount: state.backupCount + 1, remainderCount: state.remainderCount - 1);
if (!mounted) {
return;
}
final remainder = state.remainderCount > 0 ? state.remainderCount - 1 : 0;
state = state.copyWith(remainderCount: remainder, backupCount: state.totalCount - remainder);
_uploadSpeedManager.removeTask(localAssetId);
Future.delayed(const Duration(milliseconds: 1000), () {
@@ -0,0 +1,105 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/providers/backup/drift_backup.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:mocktail/mocktail.dart';
class MockForegroundUploadService extends Mock implements ForegroundUploadService {}
class MockBackgroundUploadService extends Mock implements BackgroundUploadService {}
void main() {
late MockForegroundUploadService foregroundUploadService;
late MockBackgroundUploadService backgroundUploadService;
late DriftBackupNotifier notifier;
setUpAll(() {
registerFallbackValue(Completer<void>());
registerFallbackValue(const UploadCallbacks());
});
setUp(() {
foregroundUploadService = MockForegroundUploadService();
backgroundUploadService = MockBackgroundUploadService();
notifier = DriftBackupNotifier(foregroundUploadService, backgroundUploadService, UploadSpeedManager());
addTearDown(() {
if (notifier.mounted) {
notifier.dispose();
}
});
});
Future<void> seedCounts({required int total, required int remainder, int processing = 0}) async {
when(
() => foregroundUploadService.getBackupCounts('user-1'),
).thenAnswer((_) async => (total: total, remainder: remainder, processing: processing));
await notifier.getBackupStatus('user-1');
}
// Drives a real backup so we can grab the onSuccess callback the notifier wires up.
Future<void Function(String, String)> startAndCaptureOnSuccess() async {
void Function(String, String)? onSuccess;
when(
() => foregroundUploadService.uploadCandidates(any(), any(), callbacks: any(named: 'callbacks')),
).thenAnswer((invocation) async {
onSuccess = (invocation.namedArguments[#callbacks] as UploadCallbacks).onSuccess;
});
await notifier.startForegroundBackup('user-1');
return onSuccess!;
}
group('foreground backup counts', () {
test('moves one asset from remainder to backup on each success', () async {
await seedCounts(total: 25, remainder: 25);
final onSuccess = await startAndCaptureOnSuccess();
for (var i = 0; i < 10; i++) {
onSuccess('asset-$i', 'remote-$i');
}
expect(notifier.state.remainderCount, 15);
expect(notifier.state.backupCount, 10);
expect(notifier.state.backupCount + notifier.state.remainderCount, notifier.state.totalCount);
});
test('keeps remainder at zero and backup at total when duplicates re-fire success', () async {
// Reproduces #26215: a lossy sync never records the uploads locally, so the
// app re-uploads them, the server answers 200 (duplicate) and onSuccess fires
// again. 25 real + 15 duplicate successes must not push the counts past their bounds.
await seedCounts(total: 25, remainder: 25);
final onSuccess = await startAndCaptureOnSuccess();
for (var i = 0; i < 40; i++) {
onSuccess('asset-${i % 25}', 'remote-${i % 25}');
}
expect(notifier.state.remainderCount, 0);
expect(notifier.state.backupCount, 25);
expect(notifier.state.backupCount, lessThanOrEqualTo(notifier.state.totalCount));
});
test('a refreshed status stays authoritative after duplicates drove the count down', () async {
await seedCounts(total: 25, remainder: 25);
final onSuccess = await startAndCaptureOnSuccess();
for (var i = 0; i < 40; i++) {
onSuccess('asset-${i % 25}', 'remote-${i % 25}');
}
await seedCounts(total: 25, remainder: 25);
expect(notifier.state.remainderCount, 25);
expect(notifier.state.backupCount, 0);
});
test('ignores a success that arrives after the notifier is disposed', () async {
await seedCounts(total: 25, remainder: 25);
final onSuccess = await startAndCaptureOnSuccess();
notifier.dispose();
expect(() => onSuccess('asset-late', 'remote-late'), returnsNormally);
});
});
}