From 979785b8bb32d21d73a2982c0d3b4ee9c4ab1608 Mon Sep 17 00:00:00 2001 From: bram Date: Mon, 13 Jan 2025 20:35:20 -0800 Subject: [PATCH] Added rescheduleMissingTasks Reschedules tasks that are present in the database but missing in the downloader. The assumption is that they were lost when the app was killed --- README.md | 7 ++- example/integration_test/database_test.dart | 28 ++++++++++++ lib/src/base_downloader.dart | 3 ++ lib/src/file_downloader.dart | 47 +++++++++++++++++++++ 4 files changed, 83 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 862ea48a..6105277f 100644 --- a/README.md +++ b/README.md @@ -357,8 +357,9 @@ To ensure your callbacks or listener capture events that may have happened when In summary, to track your tasks persistently, follow these steps in order, immediately after app startup: 1. If using a non-default `PersistentStorage` backend, initialize with `FileDownloader(persistentStorage: MyPersistentStorage())` and wait for the initialization to complete by calling `await FileDownloader().ready` (see [using the database](#using-the-database-to-track-tasks) for details on `PersistentStorage`). 2. Register an event listener or callback(s) to process status and progress updates -3. call `await FileDownloader().trackTasks()` if you want to track the tasks in a persistent database -4. call `await FileDownloader().resumeFromBackground()` to ensure events that happened while your app was in the background are processed +3. Call `await FileDownloader().trackTasks()` if you want to track the tasks in a persistent database +4. Call `await FileDownloader().resumeFromBackground()` to ensure events that happened while your app was in the background are processed +5. If you are tracking tasks in the database, after ~5 seconds, call `await FileDownloader().rescheduleMissingTasks()` to reschedule tasks that are in the database as `enqueued` or `running` yet are not enqueued or running on the native side. These tasks have been "lost", most likely because the user killed your app (which kills tasks on the native side without warning) The rest of this section details [event listeners](#using-an-event-listener), [callbacks](#using-callbacks) and the [database](#using-the-database-to-track-tasks) in detail. @@ -448,6 +449,8 @@ print('Taskid ${record.taskId} with task ${record.task} has ' You can interact with the `database` using `allRecords`, `allRecordsOlderThan`, `recordForId`,`deleteAllRecords`, `deleteRecordWithId` etc. If you only want to track tasks in a specific [group](#grouping-tasks), call `trackTasksInGroup` instead. +If a user kills your app (e.g. by swiping it away in the app tray) then tasks that are running (natively) are killed, and no indication is given to your application. This cannot be avoided. To guard for this, upon app startup you can ask the downloader to reschedule missing tasks, i.e. tasks that show up as `enqueued` or `running` in the database, yet are not enqueued or running on the native side. Method `rescheduleMissingTasks` returns a record with two lists, `rescheduled` and `failed`. Together, those are the missing tasks, and the successfully rescheduled ones are in the `rescheduled` list. The time to do this is a few seconds after you have called `resumeFromBackground`, as that gives the downloader time to processes updates that may have happened while the app was suspended. + By default, the downloader uses a modified version of the [localstore](https://pub.dev/packages/localstore) package to store the `TaskRecord` and other objects. To use a different persistent storage solution, create a class that implements the [PersistentStorage](https://pub.dev/documentation/background_downloader/latest/background_downloader/PersistentStorage-class.html) interface, and initialize the downloader by calling `FileDownloader(persistentStorage: MyPersistentStorage())` as the first use of the `FileDownloader`. As an alternative to LocalStore, use `SqlitePersistentStorage`, included in [background_downloader_sql](https://pub.dev/packages/background_downloader_sql), which supports SQLite storage and migration from Flutter Downloader. diff --git a/example/integration_test/database_test.dart b/example/integration_test/database_test.dart index 5535bcb9..f596790e 100644 --- a/example/integration_test/database_test.dart +++ b/example/integration_test/database_test.dart @@ -128,4 +128,32 @@ void main() { final r2 = await database.recordForId(record2.taskId); expect(r2, equals(record2)); }); + + test('rescheduleMissingTasks', () async { + expect(await FileDownloader().allTasks(), isEmpty); + // without task tracking activated, throws assertionError + expect(() async => await FileDownloader().rescheduleMissingTasks(), + throwsAssertionError); + await FileDownloader().trackTasks(); + // test empty + final result = await FileDownloader().rescheduleMissingTasks(); + expect(result.rescheduled, isEmpty); + expect(result.failed, isEmpty); + // add a record to the database that is not enqueued + await FileDownloader().database.updateRecord(record); + final result2 = await FileDownloader().rescheduleMissingTasks(); + expect(result2.rescheduled.length, equals(1)); + expect(result2.failed, isEmpty); + expect(result2.rescheduled.first.taskId, equals(task.taskId)); + final allTasks = await FileDownloader().allTasks(); + expect(allTasks.first.taskId, equals(task.taskId)); + await Future.delayed(const Duration(seconds: 2)); + expect(await FileDownloader().allTasks(), isEmpty); + // add a record to the database that is also enqueued + expect(await FileDownloader().enqueue(task2), isTrue); + expect(await FileDownloader().database.allRecords(), isNotEmpty); + final result3 = await FileDownloader().rescheduleMissingTasks(); + expect(result3.rescheduled, isEmpty); + expect(result3.failed, isEmpty); + }); } diff --git a/lib/src/base_downloader.dart b/lib/src/base_downloader.dart index 9bff4866..e4ca248a 100644 --- a/lib/src/base_downloader.dart +++ b/lib/src/base_downloader.dart @@ -516,6 +516,9 @@ abstract base class BaseDownloader { } } + /// Return true if we are tracking tasks + bool get isTrackingTasks => trackedGroups.isNotEmpty; + /// Attempt to pause this [task] /// /// Returns true if successful diff --git a/lib/src/file_downloader.dart b/lib/src/file_downloader.dart index eba61e0c..a604c4bd 100644 --- a/lib/src/file_downloader.dart +++ b/lib/src/file_downloader.dart @@ -540,6 +540,53 @@ interface class FileDownloader { Future resumeFromBackground() => _downloader.retrieveLocallyStoredData(); + /// Reschedules tasks that are present in the database but missing from + /// the native task queue. Typically called on app start, 5s after establishing + /// the updates listener, calling [trackTasks] or [trackTasksInGroup] and + /// calling [resumeFromBackground]. + /// + /// This function retrieves all tasks from the database that are in enqueued + /// or running states and compares these tasks with the + /// list of tasks currently present in the native task queue. + /// + /// For each task found only in the database, the function: + /// 1. Deletes the corresponding record from the database. + /// 2. Enqueues the task back into the native task queue. + /// + /// Finally, the function returns two lists of Tasks in a record. The first + /// item [rescheduled] is the list of successfully re-enqueued tasks, the + /// second item [failed] is the list of tasks that failed to enqueue. + /// + /// Throws assertion error if you are not currently tracking tasks, as that + /// makes this function a no-op that always returns empty lists. + Future<({List rescheduled, List failed})> + rescheduleMissingTasks() async { + assert( + _downloader.isTrackingTasks, + 'rescheduleMissingTasks should only be called if you are tracking tasks. ' + 'Did you call trackTasks or trackTasksInGroup?'); + final databaseTasks = (await database.allRecords()) + .where((record) => const [ + TaskStatus.enqueued, + TaskStatus.running, + ].contains(record.status)) + .map((record) => record.task) + .toSet(); + final nativeTasks = Set.from(await FileDownloader().allTasks()); + final missingTasks = databaseTasks.difference(nativeTasks); + final successfullyEnqueued = []; + final failedToEnqueue = []; + for (final task in missingTasks) { + await database.deleteRecordWithId(task.taskId); + if (await FileDownloader().enqueue(task)) { + successfullyEnqueued.add(task); + } else { + failedToEnqueue.add(task); + } + } + return (rescheduled: successfullyEnqueued, failed: failedToEnqueue); + } + /// Returns true if task can be resumed on pause /// /// This future only completes once the task is running and has received