Perché non basta Dio (o http)

Scaricare un file con Dio e ReceiveProgress funziona benissimo… finché l'utente resta nell'app. Nel momento in cui mette l'app in background, cambia schermata o il sistema operativo decide di sospendere il processo, il download si interrompe e bisogna ricominciare da capo. Per un PDF da 200 KB è irrilevante, per un corso video da 800 MB o per l'upload di un video registrato dall'utente è un problema serio.

La soluzione corretta è delegare il trasferimento al sistema operativo: WorkManager/DownloadManager su Android e URLSession con configurazione background su iOS. È esattamente quello che fa background_downloader: un'unica API Dart che incapsula i meccanismi nativi, con progresso, notifiche, retry, pausa e ripresa.

Installazione e configurazione

dependencies:
  background_downloader: ^9.2.0

Su Android il plugin registra da solo il servizio e il permesso POST_NOTIFICATIONS; su iOS non serve alcuna capability aggiuntiva per i trasferimenti (URLSession background è disponibile di default), ma se vuoi mostrare notifiche devi chiedere il permesso a runtime.

Nel main() conviene inizializzare il downloader prima di avviare l'app:

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Persiste i task in un database locale, così sopravvivono ai riavvii
  await FileDownloader().trackTasks();

  // Recupera gli update generati mentre l'app era terminata
  await FileDownloader().resumeFromBackground();

  await FileDownloader().configure(
    globalConfig: (Config.requestTimeout, const Duration(seconds: 60)),
    androidConfig: (Config.useCacheDir, Config.whenAble),
  );

  runApp(const MyApp());
}

Il primo download

Un trasferimento è descritto da un oggetto DownloadTask immutabile:

final task = DownloadTask(
  url: 'https://cdn.example.com/corsi/flutter-avanzato.mp4',
  filename: 'flutter-avanzato.mp4',
  directory: 'corsi',
  baseDirectory: BaseDirectory.applicationDocuments,
  updates: Updates.statusAndProgress,
  requiresWiFi: true,
  retries: 3,
  allowPause: true,
  metaData: 'corso-42',
);

Alcuni parametri meritano attenzione:

  • baseDirectory + directory: il pacchetto salva la posizione in modo relativo. È fondamentale su iOS, dove il path assoluto del container dell'app cambia a ogni aggiornamento: memorizzare /var/mobile/.../Documents/file.mp4 nel tuo database porta a file "spariti" dopo un update dallo store.
  • requiresWiFi: evita che l'utente consumi il piano dati; su Android è una constraint nativa del job.
  • retries: ritenta automaticamente con backoff esponenziale in caso di errore di rete.
  • allowPause: abilita pausa e ripresa, ma richiede che il server supporti le richieste HTTP Range.
  • metaData: campo libero per collegare il task alla tua entità di dominio (id del corso, dell'ordine, ecc.).

Modalità "attesa" (semplice)

final result = await FileDownloader().download(
  task,
  onProgress: (progress) => debugPrint('Progresso: ${(progress * 100).round()}%'),
  onStatus: (status) => debugPrint('Stato: $status'),
);

switch (result.status) {
  case TaskStatus.complete:
    final path = await task.filePath();
    debugPrint('Salvato in $path');
  case TaskStatus.canceled:
    debugPrint('Annullato dall\'utente');
  case TaskStatus.failed:
    debugPrint('Errore: ${result.exception?.description}');
  default:
    break;
}

Questa forma è comoda per download brevi, ma il Future si risolve solo finché l'app vive. Per i file pesanti usa la modalità a coda.

Modalità a coda (consigliata)

final accepted = await FileDownloader().enqueue(task);

enqueue restituisce immediatamente true se il sistema ha accettato il task: da quel momento il trasferimento è gestito dal SO e prosegue anche se l'utente chiude la schermata.

Gli aggiornamenti arrivano su uno stream globale, perfetto da collegare a un repository o a un notifier:

class DownloadRepository {
  final _progress = <String, double>{};
  final _controller = StreamController<Map<String, double>>.broadcast();

  Stream<Map<String, double>> get progressStream => _controller.stream;

  void listen() {
    FileDownloader().updates.listen((update) {
      switch (update) {
        case TaskStatusUpdate(:final task, :final status):
          if (status.isFinalState) {
            _progress.remove(task.taskId);
            _controller.add(Map.of(_progress));
          }
        case TaskProgressUpdate(:final task, :final progress):
          _progress[task.taskId] = progress;
          _controller.add(Map.of(_progress));
      }
    });
  }
}

Nota il pattern matching di Dart 3 su TaskUpdate: è una gerarchia sealed, quindi lo switch è esaustivo senza default.

Attenzione ai valori negativi di progress, che non sono percentuali ma stati speciali: -1 fallito, -2 annullato, -3 non trovato, -4 in attesa di retry, -5 in pausa. Filtra sempre con progress >= 0 prima di passarlo a un LinearProgressIndicator.

Notifiche native

Su entrambe le piattaforme puoi mostrare una notifica di sistema con barra di avanzamento e pulsanti di pausa/annulla:

await FileDownloader().permissions.request(PermissionType.notifications);

FileDownloader().configureNotification(
  running: const TaskNotification('Download in corso', '{filename} - {progress}'),
  complete: const TaskNotification('Download completato', '{filename}'),
  error: const TaskNotification('Download fallito', '{filename}'),
  paused: const TaskNotification('Download in pausa', '{filename}'),
  progressBar: true,
  tapOpensFile: false,
);

I placeholder disponibili sono {filename}, {progress}, {networkSpeed}, {timeRemaining} e {metadata}. Puoi anche intercettare il tap sulla notifica per portare l'utente nella schermata giusta:

FileDownloader().registerCallbacks(
  taskNotificationTapCallback: (task, type) {
    router.go('/corsi/${task.metaData}');
  },
);

Pausa, ripresa e cancellazione

// Richiede allowPause: true e supporto server per Range requests
await FileDownloader().pause(task);
await FileDownloader().resume(task);

// Cancellazione (anche di task avviati in una sessione precedente)
await FileDownloader().cancelTaskWithId(task.taskId);

// Stato corrente di un task in coda
final status = await FileDownloader().taskForId(task.taskId);

Persistenza dei task tra riavvii

Con trackTasks() attivo, ogni task e il suo ultimo stato vengono salvati in un database locale. È il modo più pulito per ricostruire una schermata "I miei download" al riavvio dell'app:

final records = await FileDownloader().database.allRecords();

for (final record in records) {
  debugPrint('${record.task.filename}: '
      '${record.status} (${(record.progress * 100).round()}%)');
}

// Pulizia dei record completati
await FileDownloader().database.deleteRecordWithId(someTaskId);

Puoi anche fornire un PersistentStorage personalizzato (ad esempio basato su SQLite) se vuoi integrarlo con il resto della tua persistenza.

Upload di file

L'API è speculare: cambia solo la classe del task.

final upload = UploadTask(
  url: 'https://api.example.com/v1/documenti',
  filename: 'contratto.pdf',
  directory: 'documenti',
  baseDirectory: BaseDirectory.applicationDocuments,
  fileField: 'file',
  fields: {'userId': '42', 'tipo': 'contratto'},
  headers: {'Authorization': 'Bearer $token'},
  updates: Updates.statusAndProgress,
);

await FileDownloader().enqueue(upload);

Di default viene usato un multipart/form-data. Se il backend si aspetta il contenuto grezzo nel body basta impostare post: 'binary'. Per upload multipli c'è MultiUploadTask, che accetta una lista di file con i rispettivi field name.

Un dettaglio operativo: gli header (e quindi il token di autenticazione) vengono "congelati" al momento dell'enqueue. Se il task resta in coda a lungo e il token scade, l'upload fallisce con 401. Soluzioni tipiche: accorciare la finestra accodando solo quando necessario, oppure usare FileDownloader().registerCallbacks(taskStatusCallback: ...) per rilevare il 401 (leggibile in responseStatusCode) e ri-accodare il task con un token fresco.

Operazioni in batch

Per scaricare un intero pacchetto di risorse (ad esempio i materiali di un corso) esiste downloadBatch:

final tasks = urls.map((u) => DownloadTask(url: u, directory: 'corso-42')).toList();

final batch = await FileDownloader().downloadBatch(
  tasks,
  batchProgressCallback: (succeeded, failed) {
    debugPrint('$succeeded/${tasks.length} completati, $failed falliti');
  },
);

if (batch.numFailed > 0) {
  for (final t in batch.failed) {
    debugPrint('Fallito: ${t.url}');
  }
}

Esportare il file in uno spazio condiviso

La directory dell'app non è visibile all'utente. Per spostare un file scaricato nella cartella Download o in Galleria:

final newPath = await FileDownloader().moveToSharedStorage(
  task,
  SharedStorage.downloads,
);

Su Android recenti l'operazione usa MediaStore e non richiede permessi di storage; su iOS il concetto di "shared storage" è più limitato, quindi per condividere un file è spesso preferibile il classico share sheet.

Limiti e accortezze pratiche

  • iOS non garantisce l'immediatezza: con URLSession in background il sistema decide quando eseguire i trasferimenti in base a batteria e condizioni di rete. Non progettare flussi che richiedono il completamento entro X secondi con app chiusa.
  • Se l'utente killa l'app su iOS, i trasferimenti in corso vengono terminati dal sistema: al riavvio usa resumeFromBackground() e il database per ricostruire lo stato e ri-accodare ciò che serve.
  • Non salvare path assoluti: conserva baseDirectory + directory + filename e ricalcola il path con task.filePath().
  • Verifica l'integrità: per file di grandi dimensioni confronta un checksum o almeno la dimensione attesa prima di considerare il download valido.
  • Non abusare della coda: centinaia di task simultanei degradano le performance; usa Config.holdingQueue per limitare la concorrenza (await FileDownloader().configure(globalConfig: (Config.holdingQueue, (3, null, null)));).
  • Test su device reale: l'emulatore Android e il simulatore iOS non riproducono fedelmente le politiche di background del sistema.

Quando usarlo e quando no

Usa background_downloader quando i file sono pesanti, quando il trasferimento deve sopravvivere al background o quando vuoi notifiche di sistema "gratis". Per chiamate API brevi, JSON e immagini di pochi KB continua a usare Dio o http: l'overhead di accodare un task nativo non ha senso.

Rispetto a flutter_downloader, oggi meno mantenuto e solo per i download, questo pacchetto copre anche gli upload, ha un'API moderna basata su stream e sealed class, e una gestione della persistenza integrata: nel 2024-2025 è la scelta di riferimento per i trasferimenti file in Flutter.