From 981410c6b60c017ce9e91fdd2a234e32e63414f4 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Fri, 12 Jun 2026 19:12:55 +0600 Subject: [PATCH] fix(mobile): stop the download progress bar getting stuck --- .../asset_viewer/download.provider.dart | 26 +++- .../asset_viewer/download_provider_test.dart | 139 ++++++++++++++++++ 2 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 mobile/test/providers/asset_viewer/download_provider_test.dart diff --git a/mobile/lib/providers/asset_viewer/download.provider.dart b/mobile/lib/providers/asset_viewer/download.provider.dart index 25db76b077..e45ffad4e3 100644 --- a/mobile/lib/providers/asset_viewer/download.provider.dart +++ b/mobile/lib/providers/asset_viewer/download.provider.dart @@ -9,6 +9,11 @@ import 'package:immich_mobile/services/download.service.dart'; class DownloadStateNotifier extends StateNotifier { final DownloadService _downloadService; + // Tasks that already finished. background_downloader can deliver a progress + // update after the task completed (and was removed from state); without this + // we'd re-add it as running and the progress bar would never go away. + final Set _finishedTaskIds = {}; + DownloadStateNotifier(this._downloadService) : super( const DownloadState( @@ -28,12 +33,17 @@ class DownloadStateNotifier extends StateNotifier { return; } + if (status != TaskStatus.complete) { + // A fresh attempt for this task (e.g. re-download), clear any finished mark. + _finishedTaskIds.remove(taskId); + } + state = state.copyWith( taskProgress: {} ..addAll(state.taskProgress) ..addAll({ taskId: DownloadInfo( - progress: state.taskProgress[taskId]?.progress ?? 0, + progress: status == TaskStatus.complete ? 1.0 : (state.taskProgress[taskId]?.progress ?? 0), fileName: state.taskProgress[taskId]?.fileName ?? '', status: status, ), @@ -96,6 +106,12 @@ class DownloadStateNotifier extends StateNotifier { return; } + // Ignore a late progress update for a task that already finished, otherwise it + // gets re-added as running and the progress bar never goes away. + if (_finishedTaskIds.contains(update.task.taskId)) { + return; + } + state = state.copyWith( showProgress: true, taskProgress: {} @@ -108,9 +124,17 @@ class DownloadStateNotifier extends StateNotifier { ), }), ); + + // Some downloads only ever deliver progress and never a terminal status + // callback. Once we hit 100%, schedule the cleanup ourselves so the bar + // can't stay stuck on a task that already finished. + if (update.progress >= 1.0) { + _onDownloadComplete(update.task.taskId); + } } void _onDownloadComplete(String id) { + _finishedTaskIds.add(id); Future.delayed(const Duration(seconds: 2), () { state = state.copyWith( taskProgress: {} diff --git a/mobile/test/providers/asset_viewer/download_provider_test.dart b/mobile/test/providers/asset_viewer/download_provider_test.dart new file mode 100644 index 0000000000..0b6b7d2d24 --- /dev/null +++ b/mobile/test/providers/asset_viewer/download_provider_test.dart @@ -0,0 +1,139 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; +import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; +import 'package:immich_mobile/services/download.service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockDownloadService extends Mock implements DownloadService {} + +DownloadTask _task(String id, {String filename = 'photo.jpg', String metaData = ''}) => + DownloadTask(taskId: id, url: 'https://example.com/$filename', filename: filename, metaData: metaData); + +void main() { + late MockDownloadService service; + late DownloadStateNotifier notifier; + late void Function(TaskProgressUpdate) onProgress; + late void Function(TaskStatusUpdate) onImage; + late void Function(TaskStatusUpdate) onLivePhoto; + + setUpAll(() { + registerFallbackValue(_task('fallback')); + }); + + setUp(() { + service = MockDownloadService(); + when(() => service.saveImageWithPath(any())).thenAnswer((_) async => true); + when(() => service.saveVideo(any())).thenAnswer((_) async => true); + when(() => service.saveLivePhotos(any(), any())).thenAnswer((_) async => true); + + notifier = DownloadStateNotifier(service); + addTearDown(notifier.dispose); + + // The notifier wires its private handlers onto the service in its constructor. + onProgress = verify(() => service.onTaskProgress = captureAny()).captured.last as void Function(TaskProgressUpdate); + onImage = + verify(() => service.onImageDownloadStatus = captureAny()).captured.last as void Function(TaskStatusUpdate); + onLivePhoto = + verify(() => service.onLivePhotoDownloadStatus = captureAny()).captured.last as void Function(TaskStatusUpdate); + }); + + test('fills the progress to 100% when a download completes', () { + fakeAsync((async) { + final task = _task('task-1'); + onImage(TaskStatusUpdate(task, TaskStatus.running)); + onProgress(TaskProgressUpdate(task, 0.5)); + onImage(TaskStatusUpdate(task, TaskStatus.complete)); + + expect(notifier.state.taskProgress['task-1']?.progress, 1.0); + expect(notifier.state.taskProgress['task-1']?.status, TaskStatus.complete); + + async.elapse(const Duration(seconds: 2)); + }); + }); + + test('shows the progress bar when progress arrives before any status update', () { + fakeAsync((async) { + final task = _task('task-1'); + // a brand new task whose first signal is a progress update must still show + onProgress(TaskProgressUpdate(task, 0.3)); + + expect(notifier.state.showProgress, isTrue); + expect(notifier.state.taskProgress.containsKey('task-1'), isTrue); + + onImage(TaskStatusUpdate(task, TaskStatus.complete)); + async.elapse(const Duration(seconds: 2)); + }); + }); + + test('clears the bar when progress hits 100% even without a terminal status', () { + fakeAsync((async) { + final task = _task('task-1'); + onProgress(TaskProgressUpdate(task, 0.5)); + // download finishes by progress alone, no complete status callback follows + onProgress(TaskProgressUpdate(task, 1.0)); + async.elapse(const Duration(seconds: 2)); + + expect(notifier.state.taskProgress, isEmpty); + expect(notifier.state.showProgress, isFalse); + }); + }); + + test('clears the progress bar after a completed download is removed', () { + fakeAsync((async) { + final task = _task('task-1'); + onImage(TaskStatusUpdate(task, TaskStatus.running)); + onProgress(TaskProgressUpdate(task, 0.5)); + onImage(TaskStatusUpdate(task, TaskStatus.complete)); + async.elapse(const Duration(seconds: 2)); + + expect(notifier.state.taskProgress, isEmpty); + expect(notifier.state.showProgress, isFalse); + }); + }); + + test('ignores a late progress update so the bar does not get stuck', () { + fakeAsync((async) { + final task = _task('task-1'); + onImage(TaskStatusUpdate(task, TaskStatus.running)); + onProgress(TaskProgressUpdate(task, 0.5)); + onImage(TaskStatusUpdate(task, TaskStatus.complete)); + async.elapse(const Duration(seconds: 2)); + + // a stray progress packet arrives after the task was already removed + onProgress(TaskProgressUpdate(task, 0.99)); + + expect(notifier.state.taskProgress, isEmpty); + expect(notifier.state.showProgress, isFalse); + }); + }); + + test('clears both parts of a live photo download', () { + fakeAsync((async) { + final image = _task( + 'live-image', + metaData: LivePhotosMetadata(part: LivePhotosPart.image, id: 'live-1').toJson(), + ); + final video = _task( + 'live-video', + metaData: LivePhotosMetadata(part: LivePhotosPart.video, id: 'live-1').toJson(), + ); + + onLivePhoto(TaskStatusUpdate(image, TaskStatus.running)); + onLivePhoto(TaskStatusUpdate(video, TaskStatus.running)); + onProgress(TaskProgressUpdate(image, 0.8)); + onProgress(TaskProgressUpdate(video, 0.8)); + onLivePhoto(TaskStatusUpdate(image, TaskStatus.complete)); + onLivePhoto(TaskStatusUpdate(video, TaskStatus.complete)); + async.elapse(const Duration(seconds: 2)); + + // late stragglers for either part must not bring the bar back + onProgress(TaskProgressUpdate(image, 0.95)); + onProgress(TaskProgressUpdate(video, 0.95)); + + expect(notifier.state.taskProgress, isEmpty); + expect(notifier.state.showProgress, isFalse); + }); + }); +}