Boffin Codersdurable_queueby Boffin Coders

Packages

durable_queue_workmanager

Run durable_queue tasks in Workmanager background jobs on Android and iOS, so queued work keeps going after the user leaves the app.

The app and the background job open the same queue storage. A small lease makes them take turns: the app runs the queue while it is in the foreground, the background job runs it while the app is away, and a task is never started by both.

Why a lease?

A starting DurableQueue treats every task still marked running as interrupted by a crash, and runs it again. If the background job started the queue while the app's queue was running a task, that task would run twice. On Android the job runs in a separate isolate of the same process, so in-process locks do not help either.

durable_queue_workmanager keeps one runner at a time:

Install

dependencies:
  durable_queue: ^0.5.3
  durable_queue_sqlite: ^0.2.2
  durable_queue_workmanager: ^0.1.0
  workmanager: ^0.10.10
  path_provider: ^2.1.0

Requires Flutter 3.38 or later (as Workmanager does). Use storage that several isolates can open, such as durable_queue_sqlite 0.2.2 or later; MemoryQueueStorage is not shared between isolates.

Then follow Workmanager's platform setup:

Usage

Build the queue and the lease the same way in the app and in the background job:

import 'package:durable_queue/durable_queue.dart';
import 'package:durable_queue_sqlite/durable_queue_sqlite.dart';
import 'package:durable_queue_workmanager/durable_queue_workmanager.dart';
import 'package:path_provider/path_provider.dart';
import 'package:workmanager/workmanager.dart' hide TaskStatus;

Future<DurableQueue> openQueue() async {
  final dir = await getApplicationSupportDirectory();
  final queue = DurableQueue(
    storage: SqliteQueueStorage.open('${dir.path}/queue.db'),
  );
  queue.register<UploadTask>(
    type: 'upload',
    decoder: UploadTask.fromJson,
    handler: (task, context) => api.upload(task.path),
  );
  return queue;
}

Future<QueueLease> openLease() async {
  final dir = await getApplicationSupportDirectory();
  return FileQueueLease('${dir.path}/queue.lease');
}

final background = QueueBackgroundWork(
  openQueue: openQueue,
  openLease: openLease,
  taskName: 'com.example.app.queue', // must match Info.plist on iOS
  constraints: Constraints(networkType: NetworkType.connected),
);

@pragma('vm:entry-point')
void callbackDispatcher() {
  Workmanager().executeTask((taskName, inputData) async {
    if (background.handles(taskName)) return background.run(taskName);
    return true; // your other jobs
  });
}

workmanager exports its own TaskStatus, hence hide TaskStatus when you also use durable_queue's.

In main, register the job and run the app's queue through a LeasedQueueRunner:

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Workmanager().initialize(callbackDispatcher);
  await background.schedule(); // periodic; safe to call on every start

  final queue = await openQueue();
  final runner = LeasedQueueRunner(queue, await openLease());
  LeasedQueueLifecycle.attach(runner);

  runApp(MyApp(queue: queue));
}

Enqueue from anywhere in the app with queue.enqueue(...), in the foreground or not. Tasks wait in storage until whoever holds the lease runs them.

If you use DurableQueueScope from durable_queue_flutter (0.1.1 or later) for its builders, give it runQueue: false so it leaves starting and pausing to the runner:

runApp(DurableQueueScope(queue: queue, runQueue: false, child: const MyApp()));

What runs when

App state Who runs the queue
Foreground The app, through LeasedQueueRunner. A background job that starts now finds the lease held and returns.
Going to the background The runner stops starting tasks, waits for running handlers, and releases the lease.
Background or closed The periodic job, when the operating system runs it: it drains for up to timeLimit, then closes the queue.
Back in the foreground The runner takes the lease again. If a job is still draining, it waits for it (checking every retryInterval, 5 seconds by default).

background.run returns true when the job did its part, including when the lease was held elsewhere, and false after an error (passed to onError), which lets Android retry the job with backoff.

Time limits

A run starts tasks for up to timeLimit, then lets running handlers finish. The default is 20 seconds on iOS, where a background refresh gets about 30, and 8 minutes on Android, where a worker gets 10. Keep your longest task well inside the remaining headroom, or set timeLimit yourself.

Waking up for scheduled work (Android)

After a run, if stored tasks become due before the next periodic run (a delayed task, a retry, or a task deferred by a gate or a rate limit), run registers a one-off job (wakeTaskName) for that time, at least minWakeDelay (1 minute) away. Work that was cut off by the time limit is picked up by that job too.

On iOS a one-off delay is honored only while the app is alive, so this is off there by default and the periodic job does the work. Set scheduleWakeUps to change it.

When the operating system runs jobs

Android runs the periodic job at most every 15 minutes, when its constraints are met, and may wait longer on a device that is idle or saving battery. iOS decides by itself when to run a background refresh, based on how the app is used; it can be hours. Background jobs are for work that can wait. Work the user is waiting for belongs in the foreground.

Uploads and downloads should not run inside these jobs: iOS gives a job about 30 seconds. Use durable_queue_transfer, which hands each transfer to the operating system's background transfer service, so it keeps going while the app is minimised or closed.

Longer runs on Android (foreground service)

final background = QueueBackgroundWork(
  openQueue: openQueue,
  openLease: openLease,
  foregroundService: ForegroundServiceConfig(
    notificationTitle: 'Syncing',
    notificationText: 'Sending your changes',
  ),
  timeLimit: const Duration(minutes: 30),
);

With foregroundService, the periodic and wake-up jobs run as Android foreground services: a notification is shown and the run may last longer than a normal background job, so raise timeLimit to match. While a run holds the lease the app's queue waits for it, so keep runs to minutes rather than hours and let wake-up jobs continue the work. Workmanager declares the dataSync service type; on Android 15 such services may run at most 6 hours a day, and on Android 13 and later the notification is shown only with the notification permission. iOS ignores it.

Guarantees and limits

Other leases

QueueLease is a small interface (tryAcquire, renew, release, ttl). Implement it to keep the lease somewhere else, for example in your own database. drainWithLease runs the background part without Workmanager, if you use another scheduler.

With other durable_queue packages

Contributing

Issues and pull requests: github.com/boffincoders/durable_queue. See CONTRIBUTING.md.

This page is packages/durable_queue_workmanager/README.md in the repository. Suggest a change.