fix(mobile): stop the download progress bar getting stuck

This commit is contained in:
Santo Shakil
2026-06-12 19:12:55 +06:00
parent 714c647937
commit 981410c6b6
2 changed files with 164 additions and 1 deletions
@@ -9,6 +9,11 @@ import 'package:immich_mobile/services/download.service.dart';
class DownloadStateNotifier extends StateNotifier<DownloadState> {
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<String> _finishedTaskIds = {};
DownloadStateNotifier(this._downloadService)
: super(
const DownloadState(
@@ -28,12 +33,17 @@ class DownloadStateNotifier extends StateNotifier<DownloadState> {
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: <String, DownloadInfo>{}
..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<DownloadState> {
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: <String, DownloadInfo>{}
@@ -108,9 +124,17 @@ class DownloadStateNotifier extends StateNotifier<DownloadState> {
),
}),
);
// 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: <String, DownloadInfo>{}
@@ -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);
});
});
}