mirror of
https://github.com/immich-app/immich.git
synced 2026-06-12 11:01:45 -07:00
fix(mobile): stop the download progress bar getting stuck
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user