[verified] Publish ReelBox source under GPLv3

This commit is contained in:
Hermes committed 2026-11-12 23:41:13 +00:00
commit db2b505ca3
445 files changed
+105274

No files matched your search

@@ -0,0 +1,25 @@
/// A comprehensive background file downloader and uploader for iOS, Android, Desktop and Web
///
/// Use [FileDownloader] to download and upload files.
library;
export 'src/database.dart';
export 'src/exceptions.dart';
export 'src/file_downloader.dart';
export 'src/models.dart';
export 'src/options/auth.dart';
export 'src/options/auth_callback.dart';
export 'src/options/mtls.dart';
export 'src/options/task_options.dart';
export 'src/permissions.dart' show PermissionType, PermissionStatus;
export 'src/persistent_storage.dart';
export 'src/progress_widget.dart';
export 'src/queue/task_queue.dart';
export 'src/task.dart';
export 'src/transfer.dart';
export 'src/transfers.dart';
export 'src/transfer_hint.dart';
export 'src/uri/uri_utils.dart';
export 'src/widgets/transfer_button.dart';
export 'src/widgets/transfer_list_tile.dart';
export 'src/widgets/transfer_progress_bar.dart';
File diff suppressed because it is too large. Load diff
+119
View File
@@ -0,0 +1,119 @@
import 'dart:convert';
import 'base_downloader.dart';
import 'file_downloader.dart';
import 'models.dart';
import 'task.dart';
/// Class representing a chunk of a download and its status
class Chunk {
// key parameters
final String parentTaskId;
final String url;
final String filename;
final int fromByte; // start byte
final int toByte; // end byte
final DownloadTask task; // task to download this chunk
// state parameters
late TaskStatus status;
late double progress;
/// Define a chunk by its key parameters, in default state
///
/// This also generates the [task] to download this chunk, and that
/// task contains the [parentTaskId] and [toByte] and [fromByte] values of the
/// chunk in its [Task.metaData] field as a JSON encoded map
Chunk({
required Task parentTask,
required this.url,
required this.filename,
required this.fromByte,
required this.toByte,
}) : parentTaskId = parentTask.taskId,
task = DownloadTask(
url: url,
filename: filename,
headers: {...parentTask.headers, 'Range': 'bytes=$fromByte-$toByte'},
baseDirectory: BaseDirectory.temporary,
group: BaseDownloader.chunkGroup,
updates: updatesBasedOnParent(parentTask),
retries: parentTask.retries,
allowPause: parentTask.allowPause,
priority: parentTask.priority,
requiresWiFi: parentTask.requiresWiFi,
metaData: jsonEncode({
'parentTaskId': parentTask.taskId,
'from': fromByte,
'to': toByte,
}),
) {
status = TaskStatus.enqueued;
progress = 0;
}
/// Creates object from [json]
Chunk.fromJson(Map<String, dynamic> json)
: parentTaskId = json['parentTaskId'],
url = json['url'],
filename = json['filename'],
fromByte = (json['fromByte'] as num).toInt(),
toByte = (json['toByte'] as num).toInt(),
task = Task.createFromJson(json['task']) as DownloadTask,
status = TaskStatus.values[(json['status'] as num? ?? 0).toInt()],
progress = (json['progress'] as num? ?? 0.0).toDouble();
/// Revive `List<Chunk>` from a JSON map in a jsonDecode operation,
/// where each element is a map representing the [Chunk]
static Object? listReviver(Object? key, Object? value) =>
key is int ? Chunk.fromJson(value as Map<String, dynamic>) : value;
/// Creates JSON map of this object
Map<String, dynamic> toJson() => {
'parentTaskId': parentTaskId,
'url': url,
'filename': filename,
'fromByte': fromByte,
'toByte': toByte,
'task': task.toJson(),
'status': status.index,
'progress': progress,
};
/// Return the parentTaskId embedded in the metaData of a chunkTask
static String getParentTaskId(Task task) =>
jsonDecode(task.metaData)['parentTaskId'] as String;
/// Return [Updates] that is based on the [parentTask]
static Updates updatesBasedOnParent(Task parentTask) =>
switch (parentTask.updates) {
.none || .status => Updates.status,
.progress || .statusAndProgress => Updates.statusAndProgress,
};
}
/// Resume all chunk tasks associated with this [task], and
/// return true if successful, otherwise cancels this [task]
/// which will also cancel all chunk tasks
Future<bool> resumeChunkTasks(
ParallelDownloadTask task,
ResumeData resumeData,
) async {
final chunks = List<Chunk>.from(
jsonDecode(resumeData.data, reviver: Chunk.listReviver),
);
final results = await Future.wait(
// Completed chunks retain their final file/status but have no resume data.
// Resuming them returns false and incorrectly cancels the whole parent.
chunks
.where((chunk) => chunk.status != TaskStatus.complete)
.map((chunk) => FileDownloader().resume(chunk.task)),
);
if (results.any((result) => result == false)) {
// cancel [ParallelDownloadTask] if any resume did not succeed.
// this will also cancel all chunk tasks
await FileDownloader().cancelTaskWithId(task.taskId);
return false;
}
return true;
}
+318
View File
@@ -0,0 +1,318 @@
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import 'dart:async';
import 'base_downloader.dart';
import 'exceptions.dart';
import 'models.dart';
import 'persistent_storage.dart';
import 'task.dart';
/// Persistent database used for tracking task status and progress.
///
/// Stores [TaskRecord] objects.
///
/// This object is accessed by the [FileDownloader] and [BaseDownloader]
interface class Database {
static Database? _instance;
late final PersistentStorage _storage;
final StreamController<TaskRecord> _controller =
StreamController<TaskRecord>.broadcast();
final _log = Logger('Database');
/// Return the [Database] singleton instance, and creates it if required
factory Database(PersistentStorage persistentStorage) {
_instance ??= Database._internal(persistentStorage);
return _instance!;
}
Database._internal(PersistentStorage persistentStorage) {
assert(_instance == null);
_storage = persistentStorage;
}
/// Direct access to the [PersistentStorage] object underlying the
/// database. For testing only
@visibleForTesting
PersistentStorage get storage => _storage;
/// Stream of [TaskRecord] updates, emitted after a record is updated in
/// [PersistentStorage]
Stream<TaskRecord> get updates => _controller.stream;
/// Returns all [TaskRecord]
///
/// Optionally, specify a [group] to filter by
Future<List<TaskRecord>> allRecords({String? group}) async {
final allRecords = await _storage.retrieveAllTaskRecords();
return group == null
? allRecords.toList()
: allRecords.where((element) => element.group == group).toList();
}
/// Returns all [TaskRecord] older than [age]
///
/// Optionally, specify a [group] to filter by
Future<List<TaskRecord>> allRecordsOlderThan(
Duration age, {
String? group,
}) async {
final allRecordsInGroup = await allRecords(group: group);
final now = DateTime.now();
return allRecordsInGroup
.where((record) => now.difference(record.task.creationTime) > age)
.toList();
}
/// Returns all [TaskRecord] with [TaskStatus] [status]
///
/// Optionally, specify a [group] to filter by
Future<List<TaskRecord>> allRecordsWithStatus(
TaskStatus status, {
String? group,
}) async {
final allRecordsInGroup = await allRecords(group: group);
return allRecordsInGroup
.where((record) => record.status == status)
.toList();
}
/// Return [TaskRecord] for this [taskId] or null if not found
Future<TaskRecord?> recordForId(String taskId) =>
_storage.retrieveTaskRecord(taskId);
/// Return list of [TaskRecord] corresponding to the [taskIds]
///
/// Only records that can be found in the database will be included in the
/// list. TaskIds that cannot be found will be ignored.
Future<List<TaskRecord>> recordsForIds(Iterable<String> taskIds) async {
final records = await Future.wait(taskIds.map((id) => recordForId(id)));
return records.whereType<TaskRecord>().toList();
}
/// Delete all records
///
/// Optionally, specify a [group] to filter by
Future<void> deleteAllRecords({String? group}) async {
if (group == null) {
await _storage.removeTaskRecord(null);
_updateCount = 0;
return;
}
final allRecordsInGroup = await allRecords(group: group);
await deleteRecordsWithIds(
allRecordsInGroup.map((record) => record.taskId),
);
_updateCount = 0;
}
/// Delete record with this [taskId]
Future<void> deleteRecordWithId(String taskId) =>
deleteRecordsWithIds([taskId]);
/// Delete records with these [taskIds]
Future<void> deleteRecordsWithIds(Iterable<String> taskIds) async {
await Future.wait(
taskIds.map((taskId) => _storage.removeTaskRecord(taskId)),
);
}
int _updateCount = 0;
bool _autoClean = false;
int? _maxRecordCount;
Duration? _maxAge;
bool _isCleaning = false;
bool _waitingToClean = false;
/// Whether automatic database cleanup is enabled
bool get autoClean => _autoClean;
/// Clean up the database by removing old records and/or keeping the number
/// of records below a maximum.
///
/// The [maxRecordCount] determines the maximum number of records to keep.
/// The [maxAge] determines the maximum age of records to keep.
/// If [autoClean] is true, the database will be cleaned automatically
/// every 100th update.
///
/// If both [maxRecordCount] and [maxAge] are null, the defaults are 500
/// records and 10 days.
///
/// The function returns immediately, but the actual cleanup happens
/// asynchronously and is rate-limited to deleting 5 records per second.
void cleanUp({int? maxRecordCount, Duration? maxAge, bool autoClean = true}) {
_autoClean = autoClean;
if (maxRecordCount == null && maxAge == null) {
_maxRecordCount = 500;
_maxAge = const Duration(days: 10);
} else {
_maxRecordCount = maxRecordCount;
_maxAge = maxAge;
}
_cleanDatabase();
}
Future<void> _cleanDatabase() async {
if (_isCleaning) {
_waitingToClean = true;
return;
}
_isCleaning = true;
do {
_waitingToClean = false;
try {
final allRecords = await this.allRecords();
allRecords.sort(
(a, b) => b.task.creationTime.compareTo(a.task.creationTime),
); // Newest first
final recordsToDelete = <TaskRecord>{};
final now = DateTime.now();
// Check age
if (_maxAge != null) {
final maxAge = _maxAge!;
recordsToDelete.addAll(
allRecords.where(
(record) => now.difference(record.task.creationTime) > maxAge,
),
);
}
// Check count
if (_maxRecordCount != null) {
final count = _maxRecordCount!;
if (allRecords.length > count) {
// Because we sorted newly created first, the ones after 'count' are the oldest
final recordsBeyondCount = allRecords.skip(count);
recordsToDelete.addAll(recordsBeyondCount);
}
}
_log.finest(
'Database cleanup: ${recordsToDelete.length} out of ${allRecords.length} records to delete',
);
if (recordsToDelete.isNotEmpty) {
// Rate limit deletion to ~5 per second
for (final record in recordsToDelete) {
await deleteRecordWithId(record.taskId);
await Future.delayed(const Duration(milliseconds: 200));
}
_log.finest(
'Database cleanup: ${recordsToDelete.length} records deleted}',
);
}
} catch (e) {
_log.warning('Error during database cleanup: $e');
}
} while (_waitingToClean);
_isCleaning = false;
}
/// Update or insert the record in the database
///
/// This is used by the [FileDownloader] to track tasks, and should not
/// normally be used by the user of this package
Future<void> updateRecord(TaskRecord record) async {
if (_autoClean) {
_updateCount++;
if (_updateCount % 100 == 0) {
_cleanDatabase();
}
}
await _storage.storeTaskRecord(record);
if (_controller.hasListener) {
_controller.add(record);
}
}
/// Destroy the [Database] singleton instance
///
/// For testing purposes only
void destroy() {
_instance = null;
}
}
/// Record containing task, task status and task progress.
///
/// [TaskRecord] represents the state of the task as recorded in persistent
/// storage if [FileDownloader.trackTasks] has been called to activate this.
final class TaskRecord {
final Task task;
final TaskStatus status;
final double progress;
final int expectedFileSize;
final TaskException? exception;
TaskRecord(
this.task,
this.status,
this.progress,
this.expectedFileSize, [
this.exception,
]);
/// Returns the group collection this record is stored under, which is
/// the [task]'s [Task.group]
String get group => task.group;
/// Returns the record id, which is the [task]'s [Task.taskId]
String get taskId => task.taskId;
/// Create [TaskRecord] from [json]
TaskRecord.fromJson(Map<String, dynamic> json)
: task = Task.createFromJson(json),
status = TaskStatus
.values[(json['status'] as num?)?.toInt() ?? TaskStatus.failed.index],
progress = (json['progress'] as num?)?.toDouble() ?? progressFailed,
expectedFileSize = (json['expectedFileSize'] as num?)?.toInt() ?? -1,
exception = json['exception'] == null
? null
: TaskException.fromJson(json['exception']);
/// Returns JSON map representation of this [TaskRecord]
///
/// Note the [status], [progress] and [exception] fields are merged into
/// the JSON map representation of the [task]
Map<String, dynamic> toJson() {
final json = task.toJson();
json['status'] = status.index;
json['progress'] = progress;
json['expectedFileSize'] = expectedFileSize;
json['exception'] = exception?.toJson();
return json;
}
/// Copy with optional replacements. [exception] is always copied
TaskRecord copyWith({
Task? task,
TaskStatus? status,
double? progress,
int? expectedFileSize,
}) => TaskRecord(
task ?? this.task,
status ?? this.status,
progress ?? this.progress,
expectedFileSize ?? this.expectedFileSize,
exception,
);
@override
String toString() =>
'DatabaseRecord{task: $task, status: $status, progress: $progress,'
' expectedFileSize: $expectedFileSize, exception: $exception}';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TaskRecord &&
runtimeType == other.runtimeType &&
task == other.task &&
status == other.status &&
progress == other.progress &&
expectedFileSize == other.expectedFileSize &&
exception == other.exception;
@override
int get hashCode =>
task.hashCode ^ status.hashCode ^ progress.hashCode ^ exception.hashCode;
}
@@ -0,0 +1,59 @@
import 'dart:async';
import 'dart:isolate';
import 'package:http/http.dart' as http;
import '../../background_downloader.dart';
import 'desktop_downloader.dart';
import 'download_isolate.dart';
import 'isolate.dart';
/// Do the data task
///
/// Sends updates via the [sendPort] and can be commanded to cancel via
/// the [messagesToIsolate] queue
Future<void> doDataTask(
DataTask task,
Duration requestTimeout,
SendPort sendPort,
) async {
final client = DesktopDownloader.httpClientForUrl(task.url);
final request = http.Request(task.httpRequestMethod, Uri.parse(task.url));
request.headers.addAll(task.headers);
request.maxRedirects = defaultMaxRedirects;
if (task.post is String) {
request.body = task.post!;
}
var resultStatus = TaskStatus.failed;
try {
final response = await client.send(request).timeout(requestTimeout);
if (!isCanceled) {
responseHeaders = response.headers;
responseStatusCode = response.statusCode;
extractContentType(response.headers);
responseBody = await responseContent(response);
if (okResponses.contains(response.statusCode)) {
resultStatus = TaskStatus.complete;
} else {
if (response.statusCode == 404) {
resultStatus = TaskStatus.notFound;
} else {
taskException = TaskHttpException(
responseBody?.isNotEmpty == true
? responseBody!
: response.reasonPhrase ?? 'Invalid HTTP Request',
response.statusCode,
);
}
}
}
} catch (e) {
logError(task, e.toString());
setTaskError(e);
}
if (isCanceled) {
// cancellation overrides other results
resultStatus = TaskStatus.canceled;
}
processStatusUpdateInIsolate(task, resultStatus, sendPort);
}
@@ -0,0 +1,930 @@
import 'dart:async';
import 'dart:collection';
import 'dart:io';
import 'dart:isolate';
import 'package:async/async.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:http/io_client.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import '../base_downloader.dart';
import '../chunk.dart';
import '../exceptions.dart';
import '../file_downloader.dart';
import '../models.dart';
import '../task.dart';
import '../utils.dart';
import 'isolate.dart';
const okResponses = [200, 201, 202, 203, 204, 205, 206];
/// Implementation of download functionality for desktop platforms
///
/// On desktop (MacOS, Linux, Windows) the download and upload are implemented
/// in Dart, as there is no native platform equivalent of URLSession or
/// WorkManager as there is on iOS and Android
final class DesktopDownloader extends BaseDownloader {
static final _log = Logger('DesktopDownloader');
static const unlimited = 1 << 20;
var maxConcurrent = 10;
var maxConcurrentByHost = unlimited;
var maxConcurrentByGroup = unlimited;
static final DesktopDownloader _singleton = DesktopDownloader._internal();
final _queue = PriorityQueue<Task>();
final _running = Queue<Task>(); // subset that is running
final _resume = <Task>{};
final _isolateSendPorts =
<Task, SendPort?>{}; // isolate SendPort for running task
static final Map<String, http.Client> _clientsCache = {};
static http.Client? _defaultClient;
static Duration? _requestTimeout;
static var _proxy = <String, dynamic>{}; // 'address' and 'port'
static var _bypassTLSCertificateValidation = false;
static var _mtlsConfigs = <MTLSConfig>[];
static int _skipExistingFiles = -1;
static String? tempFilePath;
factory() => _singleton;
new _internal();
@override
Future<bool> enqueue(Task task) async {
try {
Uri.decodeFull(task.url);
} catch (e) {
_log.fine('Invalid url: ${task.url} error: $e');
return false;
}
super.enqueue(task);
_queue.add(task);
processStatusUpdate(TaskStatusUpdate(task, TaskStatus.enqueued));
_advanceQueue();
return true;
}
@override
Future<List<bool>> enqueueAll(Iterable<Task> tasks) async {
final results = <bool>[];
for (final task in tasks) {
results.add(await enqueue(task));
}
return results;
}
/// Advance the queue if it's not empty and there is room in the run queue
void _advanceQueue() {
while (_running.length < maxConcurrent && _queue.isNotEmpty) {
final task = _getNextTask();
if (task != null) {
_running.add(task);
_executeTask(task)
.catchError((Object e, StackTrace stackTrace) {
// an unexpected error must not leave the task without a final
// status, or keep occupying its slot in the run queue
_log.warning('Error executing taskId ${task.taskId}: $e');
_log.fine('Stack trace: $stackTrace');
if (!_running.contains(task)) {
return; // final status was already sent
}
processStatusUpdate(
TaskStatusUpdate(
task,
TaskStatus.failed,
TaskException('Error executing task: $e'),
),
);
})
.whenComplete(() {
_remove(task);
_advanceQueue();
});
} else {
return; // if no suitable task, done
}
}
}
/// Returns a [Task] to run, or null if no suitable task is available
Task? _getNextTask() {
final tasksThatHaveToWait = <Task>[];
while (_queue.isNotEmpty) {
final task = _queue.removeFirst();
if (_numActiveWithHostname(task.hostName) < maxConcurrentByHost &&
_numActiveWithGroup(task.group) < maxConcurrentByGroup) {
_queue.addAll(tasksThatHaveToWait); // put back in queue
return task;
}
tasksThatHaveToWait.add(task);
}
_queue.addAll(tasksThatHaveToWait); // put back in queue
return null;
}
/// Returns number of tasks active with this [hostname]
int _numActiveWithHostname(String hostname) => _running.fold(
0,
(previousValue, task) =>
task.hostName == hostname ? previousValue + 1 : previousValue,
);
/// Returns number of tasks active with this [group]
int _numActiveWithGroup(String group) => _running.fold(
0,
(previousValue, task) =>
task.group == group ? previousValue + 1 : previousValue,
);
/// Execute this task
///
/// The task runs on an Isolate, which is sent the task information and
/// which will emit status and progress updates. These updates will be
/// 'forwarded' to the [backgroundChannel] and processed by the
/// [FileDownloader]
Future<void> _executeTask(Task task) async {
// Check if the file should be skipped
// (not possible if the filename will be suggested by the server)
if (task is DownloadTask && _skipExistingFiles != -1 && task.hasFilename) {
final filePath = await task.filePath();
final file = File(filePath);
if (await file.exists()) {
final fileSize = await file.length();
// threshold 0 skips any existing file, including an empty one
if (_skipExistingFiles == 0 ||
fileSize > _skipExistingFiles * 1024 * 1024) {
processStatusUpdate(
TaskStatusUpdate(task, TaskStatus.complete, null, null, null, 304),
);
return;
}
}
}
final resumeData = await getResumeData(task.taskId);
if (resumeData != null) {
await removeResumeData(task.taskId);
}
final isResume = _resume.remove(task) && resumeData != null;
// spawn an isolate to do the task
final receivePort = ReceivePort();
final errorPort = ReceivePort();
errorPort.listen((message) {
final exceptionDescription = (message as List).first as String;
final stackTrace = message.last;
logError(task, exceptionDescription);
log.fine('Stack trace: $stackTrace');
processStatusUpdate(
TaskStatusUpdate(
task,
TaskStatus.failed,
TaskException(exceptionDescription),
),
);
receivePort.close(); // also ends listener at the end
});
final RootIsolateToken? rootIsolateToken = RootIsolateToken.instance;
if (rootIsolateToken == null) {
processStatusUpdate(
TaskStatusUpdate(
task,
TaskStatus.failed,
TaskException('Could not obtain rootIsolateToken'),
),
);
return;
}
try {
log.finer('${isResume ? "Resuming" : "Starting"} taskId ${task.taskId}');
await Isolate.spawn(doTask, (
rootIsolateToken,
receivePort.sendPort,
), onError: errorPort.sendPort);
final messagesFromIsolate = StreamQueue<dynamic>(receivePort);
if (!await messagesFromIsolate.hasNext) {
return; // isolate failed before starting, reported via errorPort
}
final sendPort = await messagesFromIsolate.next as SendPort;
sendPort.send((
task,
resumeData,
isResume,
requestTimeout,
proxy,
bypassTLSCertificateValidation,
_mtlsConfigs,
tempFilePath,
));
if (_isolateSendPorts.keys.contains(task)) {
// if already registered with null value, cancel immediately
sendPort.send('cancel');
}
// store the isolate's sendPort so we can send it messages for
// cancellation, and for managing parallel downloads
_isolateSendPorts[task] = sendPort;
// listen for messages sent back from the isolate, until 'done'
// note that the task sent by the isolate may have changed. Therefore, we
// use updatedTask instead of task from here on
while (await messagesFromIsolate.hasNext) {
final message = await messagesFromIsolate.next;
switch (message) {
case 'done':
receivePort.close();
case (
'statusUpdate',
final Task updatedTask,
final TaskStatus status,
final TaskException? exception,
final String? responseBody,
final Map<String, String>? responseHeaders,
final int? responseCode,
final String? mimeType,
final String? charSet,
):
final taskStatusUpdate = TaskStatusUpdate(
updatedTask,
status,
exception,
responseBody,
responseHeaders,
responseCode,
mimeType,
charSet,
);
if (updatedTask.group != BaseDownloader.chunkGroup) {
if (status.isFinalState) {
_remove(updatedTask);
}
processStatusUpdate(taskStatusUpdate);
} else {
_parallelTaskSendPort(Chunk.getParentTaskId(updatedTask))
?.send(taskStatusUpdate);
}
case (
'progressUpdate',
final Task updatedTask,
final double progress,
final int expectedFileSize,
final double downloadSpeed,
final Duration timeRemaining,
):
final taskProgressUpdate = TaskProgressUpdate(
updatedTask,
progress,
expectedFileSize,
downloadSpeed,
timeRemaining,
);
if (updatedTask.group != BaseDownloader.chunkGroup) {
processProgressUpdate(taskProgressUpdate);
} else {
_parallelTaskSendPort(Chunk.getParentTaskId(updatedTask))
?.send(taskProgressUpdate);
}
case ('taskCanResume', final bool taskCanResume):
setCanResume(task, taskCanResume);
case (
'resumeData',
final String data,
final int requiredStartByte,
final String? eTag,
):
try {
await setResumeData(
ResumeData(task, data, requiredStartByte, eTag),
);
} catch (e) {
_log.warning('Failed to store resume data: $e');
}
// from [ParallelDownloadTask]
case ('enqueueChild', final DownloadTask childTask):
await FileDownloader().enqueue(childTask);
// from [ParallelDownloadTask]
case ('cancelTasksWithId', final List<String> taskIds):
await FileDownloader().cancelTasksWithIds(taskIds);
// from [ParallelDownloadTask]
case ('pauseTasks', final List<DownloadTask> tasks):
for (final chunkTask in tasks) {
await FileDownloader().pause(chunkTask);
}
case ('log', final String logMessage):
_log.finest(logMessage);
default:
_log.warning(
'Received message with unknown type '
'$message from Isolate',
);
}
}
} finally {
receivePort.close();
errorPort.close();
_isolateSendPorts.remove(task);
}
}
// intercept the status and progress updates for tasks that are 'chunks', i.e.
// part of a [ParallelDownloadTask]. Updates for these tasks are sent to the
// isolate running the [ParallelDownloadTask] instead
@override
void processStatusUpdate(TaskStatusUpdate update) {
// Regular update if task's group is not chunkGroup
if (update.task.group != FileDownloader.chunkGroup) {
return super.processStatusUpdate(update);
}
// If chunkGroup, send update to task's parent isolate.
// The task's metadata contains taskId of parent
_parallelTaskSendPort(Chunk.getParentTaskId(update.task))?.send(update);
}
@override
void processProgressUpdate(TaskProgressUpdate update) {
// Regular update if task's group is not chunkGroup
if (update.task.group != FileDownloader.chunkGroup) {
return super.processProgressUpdate(update);
}
// If chunkGroup, send update to task's parent isolate.
// The task's metadata contains taskId of parent
_parallelTaskSendPort(Chunk.getParentTaskId(update.task))?.send(update);
}
/// Return the [SendPort] for the [ParallelDownloadTask] represented by [taskId]
/// or null if not a [ParallelDownloadTask] or not found
SendPort? _parallelTaskSendPort(String taskId) => _isolateSendPorts.entries
.firstWhereOrNull(
(entry) =>
entry.key is ParallelDownloadTask && entry.key.taskId == taskId,
)
?.value;
@override
Future<int> reset(String group) async {
final retryAndPausedTaskCount = await super.reset(group);
final inQueueIds = _queue.unorderedElements
.where((task) => task.group == group)
.map((task) => task.taskId);
final runningIds = _running
.where((task) => task.group == group)
.map((task) => task.taskId);
final taskIds = [...inQueueIds, ...runningIds];
if (taskIds.isNotEmpty) {
await cancelTasksWithIds(taskIds);
}
return retryAndPausedTaskCount + taskIds.length;
}
@override
Future<List<Task>> allTasks(
String group,
bool includeTasksWaitingToRetry,
allGroups,
) async {
final retryAndPausedTasks = await super.allTasks(
group,
includeTasksWaitingToRetry,
allGroups,
);
if (allGroups) {
return [...retryAndPausedTasks, ..._queue.unorderedElements, ..._running];
}
final inQueue = _queue.unorderedElements.where(
(task) => task.group == group,
);
final running = _running.where((task) => task.group == group);
return [...retryAndPausedTasks, ...inQueue, ...running];
}
/// Cancels ongoing platform tasks whose taskId is in the list provided
///
/// Returns true if all cancellations were successful
@override
Future<bool> cancelPlatformTasksWithIds(List<String> taskIds) async {
final inQueue = _queue.unorderedElements
.where((task) => taskIds.contains(task.taskId))
.toList(growable: false);
for (final task in inQueue) {
processStatusUpdate(TaskStatusUpdate(task, TaskStatus.canceled));
_remove(task);
}
final running = _running.where((task) => taskIds.contains(task.taskId));
for (final task in running) {
final sendPort = _isolateSendPorts[task];
if (sendPort != null) {
sendPort.send('cancel');
_isolateSendPorts.remove(task);
} else {
// register task for cancellation even if sendPort does not yet exist:
// this will lead to immediate cancellation when the Isolate starts
_isolateSendPorts[task] = null;
}
}
return true;
}
@override
Future<Task?> taskForId(String taskId) async {
final task = await super.taskForId(taskId);
if (task != null) {
return task;
}
try {
return _running.where((task) => task.taskId == taskId).first;
} on StateError {
try {
return _queue.unorderedElements
.where((task) => task.taskId == taskId)
.first;
} on StateError {
return null;
}
}
}
@override
Future<bool> pause(Task task) async {
final sendPort = _isolateSendPorts[task];
if (sendPort != null) {
sendPort.send('pause');
return true;
}
return false;
}
@override
Future<List<bool>> pauseTaskList(Iterable<Task> tasksToPause) async {
final pauseCalls = tasksToPause.map((task) => pause(task));
return Future.wait(pauseCalls);
}
@override
Future<bool> resume(Task task) async {
if (await super.resume(task)) {
task = awaitTasks.containsKey(task)
? awaitTasks.keys.firstWhere(
(awaitTask) => awaitTask.taskId == task.taskId,
)
: task;
// Enqueue starts _executeTask, which consumes the persisted resume data.
// Retain the parent snapshot before that worker can remove it.
final resumeData = task is ParallelDownloadTask
? await getResumeData(task.taskId)
: null;
_resume.add(task);
if (await enqueue(task)) {
if (task is ParallelDownloadTask) {
if (resumeData == null) {
return false;
}
return resumeChunkTasks(task, resumeData);
}
return true;
}
}
return false;
}
@override
Future<Map<String, String>> popUndeliveredData(Undelivered dataType) =>
Future.value({});
@override
Future<String?> moveToSharedStorage(
String filePath,
SharedStorage destination,
String directory,
String? mimeType, {
bool asUriString = false,
}) async {
final fileUri = Uri.tryParse(filePath);
if (fileUri case Uri(scheme: 'file')) {
filePath = fileUri.toFilePath(
windows: defaultTargetPlatform == TargetPlatform.windows,
);
}
final destDirectoryPath = await getDestinationDirectoryPath(
destination,
directory,
);
if (destDirectoryPath == null) {
return null;
}
if (!await Directory(destDirectoryPath).exists()) {
await Directory(destDirectoryPath).create(recursive: true);
}
final fileName = path.basename(filePath);
final destFilePath = path.join(destDirectoryPath, fileName);
try {
await File(filePath).rename(destFilePath);
} on FileSystemException catch (e) {
// rename fails across volumes (e.g. EXDEV), so fall back to copy + delete
_log.fine('Could not rename $filePath ($e), trying copy and delete');
try {
await File(filePath).copy(destFilePath);
} on FileSystemException catch (e) {
_log.warning('Error moving $filePath to shared storage: $e');
return null;
}
try {
await File(filePath).delete();
} on FileSystemException catch (e) {
_log.warning('Could not delete $filePath after copying: $e');
}
}
return asUriString ? Uri.file(destFilePath).toString() : destFilePath;
}
@override
Future<String?> pathInSharedStorage(
String filePath,
SharedStorage destination,
String directory, {
bool asUriString = false,
}) async {
final destDirectoryPath = await getDestinationDirectoryPath(
destination,
directory,
);
if (destDirectoryPath == null) {
return null;
}
final fileName = path.basename(filePath);
final destFilePath = path.join(destDirectoryPath, fileName);
return asUriString ? Uri.file(destFilePath).toString() : destFilePath;
}
/// Returns the path of the destination directory in shared storage, or null
///
/// Only the .Downloads directory is supported on desktop.
/// The [directory] is appended to the base Downloads directory.
/// The directory at the returned path is not guaranteed to exist.
Future<String?> getDestinationDirectoryPath(
SharedStorage destination,
String directory,
) async {
if (destination != SharedStorage.downloads) {
_log.finer('Desktop only supports .downloads destination');
return null;
}
final downloadsDirectory = await getDownloadsDirectory();
if (downloadsDirectory == null) {
_log.warning('Could not obtain downloads directory');
return null;
}
// remove leading and trailing slashes from [directory]
var cleanDirectory = directory.replaceAll(RegExp(r'^/+'), '');
cleanDirectory = cleanDirectory.replaceAll(RegExp(r'/$'), '');
return cleanDirectory.isEmpty
? downloadsDirectory.path
: path.join(downloadsDirectory.path, cleanDirectory);
}
@override
Future<bool> openFile(Task? task, String? filePath, String? mimeType) async {
final executable = defaultTargetPlatform == TargetPlatform.linux
? 'xdg-open'
: defaultTargetPlatform == TargetPlatform.macOS
? 'open'
: 'start';
filePath ??= await task!.filePath();
if (!await File(filePath).exists()) {
_log.fine('File to open does not exist: $filePath');
return false;
}
// On Windows, 'start' treats its first quoted argument as the window
// title, so pass an empty title before the (possibly quoted) filePath
final result = defaultTargetPlatform == TargetPlatform.windows
? await Process.run('cmd', ['/c', 'start', '', filePath])
: await Process.run(executable, [filePath], runInShell: true);
if (result.exitCode != 0) {
_log.fine(
'openFile command $executable returned exit code ${result.exitCode}',
);
}
return result.exitCode == 0;
}
@override
Future<Duration> getTaskTimeout() => Future.value(const Duration(days: 1));
@override
Future<void> setForceFailPostOnBackgroundChannel(bool value) {
throw UnimplementedError();
}
@override
Future<String> testSuggestedFilename(
DownloadTask task,
String contentDisposition,
) async {
final h = contentDisposition.isNotEmpty
? {'Content-disposition': contentDisposition}
: <String, String>{};
final t = await taskWithSuggestedFilename(task, h, false);
return t.filename;
}
@override
dynamic platformConfig({
dynamic globalConfig,
dynamic androidConfig,
dynamic iOSConfig,
dynamic desktopConfig,
}) => desktopConfig;
@override
Future<(String, String)> configureItem((String, dynamic) configItem) async {
switch (configItem) {
case (Config.requestTimeout, final Duration? duration):
requestTimeout = duration;
case (Config.proxy, (final String address, final int port)):
proxy = {'address': address, 'port': port};
case (Config.proxy, false):
proxy = {};
case (Config.bypassTLSCertificateValidation, final bool bypass):
bypassTLSCertificateValidation = bypass;
case (Config.mTLS, final MTLSConfig config):
mtlsConfig = config;
case (Config.mTLS, false):
case (Config.mTLS, null):
resetMtlsConfig();
case (
Config.holdingQueue,
(
final int? maxConcurrentParam,
final int? maxConcurrentByHostParam,
final int? maxConcurrentByGroupParam,
),
):
maxConcurrent = maxConcurrentParam ?? 10;
maxConcurrentByHost = maxConcurrentByHostParam ?? unlimited;
maxConcurrentByGroup = maxConcurrentByGroupParam ?? unlimited;
case (Config.holdingQueue, Config.never):
case (Config.holdingQueue, false):
maxConcurrent = unlimited;
maxConcurrentByHost = unlimited;
maxConcurrentByGroup = unlimited;
case (Config.skipExistingFiles, final int value):
_skipExistingFiles = value;
case (Config.skipExistingFiles, Config.never):
case (Config.skipExistingFiles, false):
_skipExistingFiles = -1;
case (Config.skipExistingFiles, Config.always):
case (Config.skipExistingFiles, true):
_skipExistingFiles = 0;
case (Config.tempFilePath, final String path):
tempFilePath = (path.isNotEmpty && path != Config.never) ? path : null;
case (Config.tempFilePath, null):
case (Config.tempFilePath, false):
tempFilePath = null;
default:
return (
configItem.$1,
'not implemented',
); // this method did not process this configItem
}
return (configItem.$1, ''); // normal result
}
/// Sets requestTimeout and recreates HttpClient
static set requestTimeout(Duration? value) {
_requestTimeout = value;
_recreateClient();
}
static Duration? get requestTimeout => _requestTimeout;
/// Sets proxy and recreates HttpClient
///
/// Value must be dict containing 'address' and 'port'
/// or empty for no proxy
static set proxy(Map<String, dynamic> value) {
_proxy = value;
_recreateClient();
}
static Map<String, dynamic> get proxy => _proxy;
/// Set or resets bypass for TLS certificate validation
static set bypassTLSCertificateValidation(bool value) {
_bypassTLSCertificateValidation = value;
_recreateClient();
}
static bool get bypassTLSCertificateValidation =>
_bypassTLSCertificateValidation;
/// Set or reset an [MTLSConfig]
static set mtlsConfig(MTLSConfig? config) {
if (config == null || config.isReset) {
resetMtlsConfig(config?.host);
} else {
final configHost = config.host?.toLowerCase();
_mtlsConfigs.removeWhere((c) => c.host?.toLowerCase() == configHost);
_mtlsConfigs.add(config);
_recreateClient();
}
}
/// Resets mTLS configuration for [host] or all hosts if [host] is null
static void resetMtlsConfig([String? host]) {
if (host == null) {
_mtlsConfigs.clear();
} else {
final normHost = host.toLowerCase();
_mtlsConfigs.removeWhere((c) => c.host?.toLowerCase() == normHost);
}
_recreateClient();
}
/// Returns active mTLS configurations
static List<MTLSConfig> get mtlsConfigs => List.unmodifiable(_mtlsConfigs);
/// Sets all mTLS configurations
static set mtlsConfigs(List<MTLSConfig> configs) {
_mtlsConfigs = List.from(configs);
_recreateClient();
}
/// Set the HTTP Client to use, with the given parameters
///
/// This is a convenience method, bundling the [requestTimeout],
/// [proxy], [bypassTLSCertificateValidation] and [mtlsConfigs]
static void setHttpClient(
Duration? requestTimeout,
Map<String, dynamic> proxy,
bool bypassTLSCertificateValidation, [
List<MTLSConfig>? mtlsConfigs,
]) {
_requestTimeout = requestTimeout;
_proxy = proxy;
_bypassTLSCertificateValidation = bypassTLSCertificateValidation;
if (mtlsConfigs != null) {
_mtlsConfigs = List.from(mtlsConfigs);
}
_recreateClient();
}
/// Returns an [http.Client] configured for the given [url].
static http.Client httpClientForUrl(String? url) {
final host = url != null ? Uri.tryParse(url)?.host : null;
return httpClientForHost(host);
}
/// Returns an [http.Client] configured for the given [host].
static http.Client httpClientForHost(String? host) {
if (_mtlsConfigs.isEmpty) {
return _defaultClient ??= _createRawClient(null);
}
final normHost = host?.toLowerCase();
final matchedConfig =
(normHost != null
? _mtlsConfigs.firstWhereOrNull(
(c) => c.host?.toLowerCase() == normHost && c.hasCredentials,
)
: null) ??
_mtlsConfigs.firstWhereOrNull(
(c) => c.host == null && c.hasCredentials,
);
if (matchedConfig == null) {
return _defaultClient ??= _createRawClient(null);
}
final cacheKey = matchedConfig.host?.toLowerCase() ?? '*';
return _clientsCache.putIfAbsent(
cacheKey,
() => _createRawClient(matchedConfig),
);
}
static const defaultMaxRedirects = 10;
/// Default HTTP client getter for backward compatibility
static http.Client get httpClient => httpClientForHost(null);
/// Sets the default HTTP client (primarily for testing)
static set httpClient(http.Client client) {
_defaultClient = client is _RedirectClient
? client
: _RedirectClient(client);
}
static http.Client _createRawClient(MTLSConfig? mtlsConfig) {
SecurityContext? securityContext;
if (mtlsConfig != null && mtlsConfig.hasCredentials) {
_log.finest(
'Creating HTTP client with mTLS configuration for host: ${mtlsConfig.host ?? "all hosts"}',
);
securityContext = SecurityContext(withTrustedRoots: true);
mtlsConfig.applyToSecurityContext(securityContext);
}
final client = HttpClient(context: securityContext);
client.connectionTimeout = requestTimeout;
client.findProxy = proxy.isNotEmpty
? (_) => 'PROXY ${_proxy['address']}:${_proxy['port']}'
: null;
client.badCertificateCallback =
bypassTLSCertificateValidation && !kReleaseMode
? (X509Certificate cert, String host, int port) => true
: null;
return _RedirectClient(IOClient(client));
}
/// Recreates the HTTP client instances used for Requests and isolate downloads/uploads
static void _recreateClient() {
for (final client in _clientsCache.values) {
client.close();
}
_clientsCache.clear();
_defaultClient?.close();
_defaultClient = null;
if (bypassTLSCertificateValidation) {
if (kReleaseMode) {
throw ArgumentError(
'You cannot bypass certificate validation in release mode',
);
} else {
_log.warning(
'TLS certificate validation is bypassed. This is insecure and cannot be '
'done in release mode',
);
}
}
_log.finest(
'Using HTTP client with requestTimeout $_requestTimeout, proxy $_proxy, TLSCertificateBypass = $bypassTLSCertificateValidation, mTLS configs = ${_mtlsConfigs.length}',
);
}
@override
void destroy() {
super.destroy();
_queue.clear();
_running.clear();
_isolateSendPorts.clear();
}
/// Remove all references to [task]
void _remove(Task task) {
_queue.remove(task);
_running.remove(task);
_isolateSendPorts.remove(task);
}
}
/// The default maximum number of redirects to follow for HTTP requests (10).
///
/// On Desktop platforms (macOS, Windows, Linux) and for [FileDownloader.request],
/// up to 10 redirects are followed. Mobile platforms follow their native defaults
/// (iOS allows up to 16, Android allows up to 20).
const defaultMaxRedirects = DesktopDownloader.defaultMaxRedirects;
/// Wrapper around [http.Client] that raises [http.BaseRequest.maxRedirects] to
/// [defaultMaxRedirects] if it is still at the Dart default of 5.
final class _RedirectClient extends http.BaseClient {
final http.Client _inner;
_RedirectClient(this._inner);
@override
Future<http.StreamedResponse> send(http.BaseRequest request) {
if (request.maxRedirects == 5) {
request.maxRedirects = defaultMaxRedirects;
}
return _inner.send(request);
}
@override
void close() {
_inner.close();
super.close();
}
}
@@ -0,0 +1,366 @@
import 'dart:async';
import 'dart:io';
import 'dart:isolate';
import 'dart:math';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../exceptions.dart';
import '../models.dart';
import '../task.dart';
import '../utils.dart';
import 'desktop_downloader.dart';
import 'isolate.dart';
var taskRangeStartByte = 0; // Start of the Task's download range
String? eTagHeader;
late DownloadTask downloadTask; // global because filename may change
/// Execute the download task
///
/// Sends updates via the [sendPort] and can be commanded to cancel/pause via
/// the [messagesToIsolate] queue
Future<void> doDownloadTask(
DownloadTask task,
ResumeData? resumeData,
bool isResume,
Duration requestTimeout,
String? tempFilePathConfig,
SendPort sendPort,
) async {
// use downloadTask from here on as a 'global' variable in this isolate,
// as we may change the filename of the task
downloadTask = task;
var filePath = await downloadTask.filePath();
// tempFilePath is taken from [resumeDataString] if this is a resuming task.
// Otherwise, it is a generated full path to the temp directory
final tempFilePath = isResume && resumeData != null
? resumeData.tempFilepath
: p.join(
tempFilePathConfig ?? (await getTemporaryDirectory()).path,
'com.bbflight.background_downloader${Random().nextInt(1 << 32).toString()}',
);
final requiredStartByte =
resumeData?.requiredStartByte ?? 0; // start for resume
final eTag = resumeData?.eTag;
isResume =
isResume &&
await determineIfResumeIsPossible(tempFilePath, requiredStartByte);
final client = DesktopDownloader.httpClientForUrl(downloadTask.url);
final request = http.Request(
downloadTask.httpRequestMethod,
Uri.parse(downloadTask.url),
);
request.headers.addAll(downloadTask.headers);
request.maxRedirects = defaultMaxRedirects;
request.persistentConnection = false;
if (isResume) {
final taskRangeHeader = downloadTask.headers['Range'] ?? '';
final taskRange = parseRange(taskRangeHeader);
taskRangeStartByte = taskRange.$1;
final resumeRange = (taskRangeStartByte + requiredStartByte, taskRange.$2);
final newRangeString = 'bytes=${resumeRange.$1}-${resumeRange.$2 ?? ""}';
request.headers['Range'] = newRangeString;
}
switch (downloadTask.postBody) {
case final Uint8List bytes:
request.bodyBytes = bytes;
case final String post:
request.body = post;
}
var resultStatus = TaskStatus.failed;
try {
final response = await client.send(request).timeout(requestTimeout);
if (!isCanceled) {
eTagHeader = response.headers['etag'] ?? response.headers['ETag'];
final acceptRangesHeader = response.headers['accept-ranges'];
final serverAcceptsRanges =
acceptRangesHeader == 'bytes' || response.statusCode == 206;
var taskCanResume = false;
if (downloadTask.allowPause) {
// determine if this task can be paused
taskCanResume = serverAcceptsRanges;
sendPort.send(('taskCanResume', taskCanResume));
}
isResume =
isResume && response.statusCode == 206; // confirm resume response
if (isResume && (eTagHeader != eTag || eTag?.startsWith('W/') == true)) {
throw TaskException('Cannot resume: ETag is not identical, or is weak');
}
if (!downloadTask.hasFilename) {
downloadTask = await taskWithSuggestedFilename(
downloadTask,
response.headers,
true,
);
// update the filePath by replacing the last segment with the new filename
filePath = p.join(p.dirname(filePath), downloadTask.filename);
log.finest(
'Suggested filename for taskId ${task.taskId}: ${task.filename}',
);
}
responseHeaders = response.headers;
responseStatusCode = response.statusCode;
extractContentType(response.headers);
if (okResponses.contains(response.statusCode)) {
resultStatus = await processOkDownloadResponse(
filePath,
tempFilePath,
serverAcceptsRanges,
taskCanResume,
isResume,
requestTimeout,
response,
sendPort,
);
} else {
// not an OK response
responseBody = await responseContent(response);
if (response.statusCode == 404) {
resultStatus = TaskStatus.notFound;
} else {
taskException = TaskHttpException(
responseBody?.isNotEmpty == true
? responseBody!
: response.reasonPhrase ?? 'Invalid HTTP Request',
response.statusCode,
);
}
}
}
} catch (e) {
logError(downloadTask, e.toString());
setTaskError(e);
}
if (isCanceled) {
// cancellation overrides other results
resultStatus = TaskStatus.canceled;
}
processStatusUpdateInIsolate(downloadTask, resultStatus, sendPort);
}
/// Return true if resume is possible
///
/// Confirms that file at [tempFilePath] exists and its length equals
/// [requiredStartByte]
Future<bool> determineIfResumeIsPossible(
String tempFilePath,
int requiredStartByte,
) async {
if (File(tempFilePath).existsSync()) {
if (await File(tempFilePath).length() == requiredStartByte) {
return true;
} else {
log.fine('Partially downloaded file is corrupted, resume not possible');
}
} else {
log.fine('Partially downloaded file not available, resume not possible');
}
return false;
}
/// Process response with valid response code
///
/// Performs the actual bytes transfer from response to a temp file,
/// and handles the result of the transfer:
/// - .complete -> copy temp to final file location
/// - .failed -> delete temp file
/// - .paused -> post resume information
Future<TaskStatus> processOkDownloadResponse(
String filePath,
String tempFilePath,
bool serverAcceptsRanges,
bool taskCanResume,
bool isResume,
Duration requestTimeout,
http.StreamedResponse response,
SendPort sendPort,
) async {
// contentLength is extracted from response header, and if not available
// we attempt to extract from [Task.headers], allowing developer to
// set the content length if already known
final contentLength = getContentLength(response.headers, downloadTask);
isResume = isResume && response.statusCode == 206;
if (isResume && !await prepareResume(response, tempFilePath)) {
deleteTempFile(tempFilePath);
return TaskStatus.failed;
}
var resultStatus = TaskStatus.failed;
var actualTempFilePath = tempFilePath;
IOSink? outStream;
try {
// do the actual download
try {
Directory(p.dirname(actualTempFilePath)).createSync(recursive: true);
outStream = File(actualTempFilePath)
.openWrite(mode: isResume ? FileMode.append : FileMode.write);
} catch (e) {
if (!isResume) {
// Fallback to the target download directory as a hidden file
final targetDir = p.dirname(filePath);
Directory(targetDir).createSync(recursive: true);
actualTempFilePath = p.join(targetDir, '.${p.basename(tempFilePath)}');
log.info(
'Standard temporary directory not writeable ($e). '
'Falling back to target directory temp file: $actualTempFilePath',
);
outStream = File(actualTempFilePath).openWrite(mode: FileMode.write);
} else {
rethrow;
}
}
final transferBytesResult = await transferBytes(
response.stream,
outStream,
contentLength,
downloadTask,
sendPort,
requestTimeout,
);
switch (transferBytesResult) {
case .complete:
// copy file to destination, creating dirs if needed
await outStream.flush();
final dirPath = p.dirname(filePath);
Directory(dirPath).createSync(recursive: true);
File(actualTempFilePath).copySync(filePath);
resultStatus = TaskStatus.complete;
case .canceled:
// temp file is deleted in finally, after closing outStream
resultStatus = TaskStatus.canceled;
case .paused:
if (taskCanResume) {
sendPort.send((
'resumeData',
actualTempFilePath,
bytesTotal + startByte,
eTagHeader,
));
resultStatus = TaskStatus.paused;
} else {
taskException = TaskResumeException(
'Task was paused but cannot resume',
);
resultStatus = TaskStatus.failed;
}
case .failed:
break;
default:
throw ArgumentError('Cannot process $transferBytesResult');
}
} catch (e) {
logError(downloadTask, e.toString());
setTaskError(e);
} finally {
try {
try {
await outStream?.close();
} catch (_) {}
if (resultStatus == TaskStatus.failed &&
serverAcceptsRanges &&
(bytesTotal + startByte > 1 << 20 || isResume)) {
// send ResumeData to allow resume after fail
sendPort.send((
'resumeData',
actualTempFilePath,
bytesTotal + startByte,
eTagHeader,
));
} else if (resultStatus != TaskStatus.paused &&
File(actualTempFilePath).existsSync()) {
File(actualTempFilePath).deleteSync();
}
} catch (e) {
logError(
downloadTask,
'Could not delete temp file $actualTempFilePath: $e',
);
}
}
return resultStatus;
}
/// Prepare for resume if possible
///
/// Returns true if task can continue, false if task failed.
/// Extracts and parses Range headers, and truncates temp file
Future<bool> prepareResume(
http.StreamedResponse response,
String tempFilePath,
) async {
final range = response.headers['content-range'];
if (range == null) {
log.fine('Could not process partial response Content-Range');
taskException = TaskResumeException(
'Could not process partial response Content-Range',
);
return false;
}
final contentRangeRegEx = RegExp(r"(\d+)-(\d+)/(\d+)");
final matchResult = contentRangeRegEx.firstMatch(range);
if (matchResult == null) {
log.fine('Could not process partial response Content-Range $range');
taskException = TaskResumeException(
'Could not process '
'partial response Content-Range $range',
);
return false;
}
final start = int.parse(matchResult.group(1) ?? '0');
final end = int.parse(matchResult.group(2) ?? '0');
final total = int.parse(matchResult.group(3) ?? '0');
final tempFile = File(tempFilePath);
final tempFileLength = await tempFile.length();
log.finest(
'Resume start=$start, end=$end of total=$total bytes, tempFile = $tempFileLength bytes',
);
startByte = start - taskRangeStartByte; // relative to start of range
if (startByte > tempFileLength) {
log.fine('Offered range not feasible: $range with startByte $startByte');
taskException = TaskResumeException(
'Offered range not feasible: $range with startByte $startByte',
);
return false;
}
try {
final file = await tempFile.open(mode: FileMode.writeOnlyAppend);
await file.truncate(startByte);
await file.close();
} on FileSystemException {
log.fine('Could not truncate temp file');
taskException = TaskResumeException('Could not truncate temp file');
return false;
}
return true;
}
/// Delete the temporary file
void deleteTempFile(String tempFilePath) async {
try {
File(tempFilePath).deleteSync();
} on FileSystemException {
log.fine('Could not delete temp file $tempFilePath');
}
}
/// Extract content type from [headers] and set [mimeType] and [charSet]
void extractContentType(Map<String, String> headers) {
final contentType = headers['content-type'];
if (contentType != null) {
final regEx = RegExp(r'(.*);\s*charset\s*=(.*)');
final match = regEx.firstMatch(contentType);
if (match != null) {
mimeType = match.group(1);
charSet = match.group(2);
} else {
mimeType = contentType;
}
}
}
@@ -0,0 +1,502 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
import 'dart:math';
import 'package:async/async.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:logging/logging.dart';
import '../exceptions.dart';
import '../models.dart';
import '../task.dart';
import 'data_isolate.dart';
import 'desktop_downloader.dart';
import 'download_isolate.dart';
import 'parallel_download_isolate.dart';
import 'upload_isolate.dart';
/// global variables, unique to this isolate
var bytesTotal = 0; // total bytes read in this download session
var startByte =
0; // starting position within the original range, used for resume
var lastProgressUpdateTime = DateTime.fromMillisecondsSinceEpoch(0);
var nextProgressUpdateTime = DateTime.fromMillisecondsSinceEpoch(0);
var lastProgressUpdate = 0.0;
var bytesTotalAtLastProgressUpdate = 0;
var networkSpeed = 0.0; // in MB/s
var isPaused = false;
var isCanceled = false;
// additional parameters for final TaskStatusUpdate
TaskException? taskException;
String? responseBody;
Map<String, String>? responseHeaders;
int? responseStatusCode;
String? mimeType; // derived from Content-Type header
String? charSet; // derived from Content-Type header
// logging from isolate is always 'FINEST', as it is sent to
// the [DesktopDownloader] for processing
final log = Logger('FileDownloader');
/// Do the task, sending messages back to the main isolate via [sendPort]
///
/// The first message sent back is a [ReceivePort] that is the command port
/// for the isolate. The first command must be the arguments: task and filePath.
Future<void> doTask((RootIsolateToken, SendPort) isolateArguments) async {
final (rootIsolateToken, sendPort) = isolateArguments;
BackgroundIsolateBinaryMessenger.ensureInitialized(rootIsolateToken);
final receivePort = ReceivePort();
// send the receive port back to the main Isolate
sendPort.send(receivePort.sendPort);
final messagesToIsolate = StreamQueue<dynamic>(receivePort);
// get the arguments list and parse each argument
final (
Task originalTask,
ResumeData? resumeData,
bool isResume,
Duration? requestTimeout,
Map<String, dynamic> proxy,
bool bypassTLSCertificateValidation,
List<MTLSConfig> mtlsConfigs,
String? tempFilePathConfig,
) = await messagesToIsolate.next;
DesktopDownloader.setHttpClient(
requestTimeout,
proxy,
bypassTLSCertificateValidation,
mtlsConfigs,
);
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((LogRecord rec) {
if (kDebugMode) {
sendPort.send(('log', (rec.message)));
}
});
// process native callbacks beforeTaskStart, onTaskStart and onAuth
final statusUpdate = await originalTask.options?.beforeTaskStartCallBack
?.call(originalTask);
if (statusUpdate != null) {
log.fine(
'TaskId ${originalTask.taskId} interrupted by beforeTaskStart callback',
);
// set global vars for final TaskStatusUpdate
taskException = statusUpdate.exception;
responseBody = statusUpdate.responseBody;
responseHeaders = statusUpdate.responseHeaders;
responseStatusCode = statusUpdate.responseStatusCode;
processStatusUpdateInIsolate(originalTask, statusUpdate.status, sendPort);
return;
}
final task = await getModifiedTask(
originalTask,
); // processes onStart and onAuth
// start listener/processor for incoming messages
unawaited(listenToIncomingMessages(task, messagesToIsolate, sendPort));
processStatusUpdateInIsolate(task, TaskStatus.running, sendPort);
if (!isResume) {
processProgressUpdateInIsolate(task, 0.0, sendPort);
}
if (task.retriesRemaining < 0) {
const message = 'Task has negative retries remaining';
logError(task, message);
taskException = TaskException(message);
processStatusUpdateInIsolate(task, TaskStatus.failed, sendPort);
} else {
// allow immediate cancel message to come through
await Future.delayed(const Duration(milliseconds: 0));
await switch (task) {
ParallelDownloadTask() => doParallelDownloadTask(
task,
resumeData,
isResume,
requestTimeout ?? const Duration(seconds: 60),
sendPort,
),
DownloadTask() => doDownloadTask(
task,
resumeData,
isResume,
requestTimeout ?? const Duration(seconds: 60),
tempFilePathConfig,
sendPort,
),
UploadTask() => doUploadTask(task, sendPort),
DataTask() => doDataTask(
task,
requestTimeout ?? const Duration(seconds: 60),
sendPort,
),
_ => throw UnimplementedError(),
};
}
DesktopDownloader.httpClient.close();
receivePort.close();
sendPort.send('done'); // signals end
Isolate.exit();
}
/// Listen async to messages to the isolate, and process these
///
/// Called as unawaited Future, which completes when the [messagesToIsolate]
/// stream is closed
Future<void> listenToIncomingMessages(
Task task,
StreamQueue messagesToIsolate,
SendPort sendPort,
) async {
while (await messagesToIsolate.hasNext) {
final message = await messagesToIsolate.next;
switch (message) {
case 'cancel':
isCanceled = true; // checked in loop elsewhere
if (task is ParallelDownloadTask) {
cancelParallelDownloadTask(task, sendPort);
}
case 'pause':
isPaused = true; // checked in loop elsewhere
if (task is ParallelDownloadTask) {
pauseParallelDownloadTask(task, sendPort);
}
// Status and progress updates are incoming from chunk tasks, part of a
// [ParallelDownloadTask]. We update the chunk status/progress and
// determine the aggregate status/progress for the parent task. If changed,
// we process that update for the parent task as we would for a regular
// [DownloadTask].
// Note that [task] refers to the parent task, whereas [update.task] refers
// to the chunk (child) task
case final TaskStatusUpdate update:
await chunkStatusUpdate(task, update, sendPort);
case final TaskProgressUpdate update:
chunkProgressUpdate(task, update, sendPort);
}
}
}
/// Transfer all bytes from [inStream] to [outStream], expecting [contentLength]
/// total bytes
///
/// Sends updates via the [sendPort] and can be commanded to cancel/pause via
/// the [messagesToIsolate] queue
///
/// Returns a [TaskStatus] and will throw any exception generated within
///
/// Note: does not flush or close any streams
Future<TaskStatus> transferBytes(
Stream<List<int>> inStream,
StreamSink<List<int>> outStream,
int contentLength,
Task task,
SendPort sendPort, [
Duration requestTimeout = const Duration(seconds: 60),
]) async {
if (contentLength == 0) {
contentLength = -1;
}
var resultStatus = TaskStatus.complete;
try {
await outStream.addStream(
inStream
.timeout(
requestTimeout,
onTimeout: (sink) {
taskException = TaskConnectionException('Connection timed out');
resultStatus = TaskStatus.failed;
sink.close(); // ends the stream
},
)
.map((bytes) {
if (isCanceled) {
resultStatus = TaskStatus.canceled;
throw StateError('Canceled');
}
if (isPaused) {
resultStatus = TaskStatus.paused;
throw StateError('Paused');
}
bytesTotal += bytes.length;
final progress = min(
(bytesTotal + startByte).toDouble() / (contentLength + startByte),
0.999,
);
final now = DateTime.now();
if (contentLength > 0 && shouldSendProgressUpdate(progress, now)) {
processProgressUpdateInIsolate(
task,
progress,
sendPort,
contentLength + startByte,
);
lastProgressUpdate = progress;
nextProgressUpdateTime = now.add(
const Duration(milliseconds: 500),
);
}
return bytes;
}),
);
} catch (e) {
if (resultStatus == TaskStatus.complete) {
// this was an unintentional error thrown within the stream processing
logError(task, e.toString());
setTaskError(e);
resultStatus = TaskStatus.failed;
}
}
return resultStatus;
}
/// Processes a change in status for the [task]
///
/// Sends status update via the [sendPort], if requested
/// If the task is finished, processes a final progressUpdate update
void processStatusUpdateInIsolate(
Task task,
TaskStatus status,
SendPort sendPort,
) {
final retryNeeded = status == TaskStatus.failed && task.retriesRemaining > 0;
// if task is in final state, process a final progressUpdate
// A 'failed' progress update is only provided if
// a retry is not needed: if it is needed, a `waitingToRetry` progress update
// will be generated in the FileDownloader
switch (status) {
case .complete:
processProgressUpdateInIsolate(task, progressComplete, sendPort);
case .failed when !retryNeeded:
processProgressUpdateInIsolate(task, progressFailed, sendPort);
case .canceled:
processProgressUpdateInIsolate(task, progressCanceled, sendPort);
case .notFound:
processProgressUpdateInIsolate(task, progressNotFound, sendPort);
case .paused:
processProgressUpdateInIsolate(task, progressPaused, sendPort);
default:
{}
}
final statusUpdate = TaskStatusUpdate(
task,
status,
status == TaskStatus.failed ? taskException ?? TaskException('None') : null,
status.isFinalState ? responseBody : null,
status.isFinalState ? responseHeaders : null,
status == TaskStatus.complete || status == TaskStatus.notFound
? responseStatusCode
: null,
status.isFinalState ? mimeType : null,
status.isFinalState ? charSet : null,
);
// Post update if task expects one, or if failed and retry is needed
if (task.providesStatusUpdates || retryNeeded) {
sendPort.send((
'statusUpdate',
statusUpdate.task,
statusUpdate.status,
statusUpdate.exception,
statusUpdate.responseBody,
statusUpdate.responseHeaders,
statusUpdate.responseStatusCode,
statusUpdate.mimeType,
statusUpdate.charSet,
));
}
if (status.isFinalState) {
task.options?.onTaskFinishedCallBack?.call(statusUpdate);
}
}
/// Processes a progress update for the [task]
///
/// Sends progress update via the [sendPort], if requested
void processProgressUpdateInIsolate(
Task task,
double progress,
SendPort sendPort, [
int expectedFileSize = -1,
]) {
if (task.providesProgressUpdates) {
if (progress > 0 && progress < 1) {
// calculate download speed and time remaining
final now = DateTime.now();
final timeSinceLastUpdate = now.difference(lastProgressUpdateTime);
lastProgressUpdateTime = now;
if (task is ParallelDownloadTask) {
// approximate based on aggregate progress
bytesTotal = (progress * expectedFileSize).floor();
}
final bytesSinceLastUpdate = bytesTotal - bytesTotalAtLastProgressUpdate;
bytesTotalAtLastProgressUpdate = bytesTotal;
final currentNetworkSpeed = timeSinceLastUpdate.inHours > 0
? -1.0
: bytesSinceLastUpdate / timeSinceLastUpdate.inMicroseconds;
networkSpeed = switch (currentNetworkSpeed) {
-1.0 => -1.0,
_ when networkSpeed == -1.0 => currentNetworkSpeed,
_ => (networkSpeed * 3 + currentNetworkSpeed) / 4.0,
};
final remainingBytes = (1 - progress) * expectedFileSize;
final timeRemaining = networkSpeed == -1.0 || expectedFileSize < 0
? const Duration(seconds: -1)
: Duration(microseconds: (remainingBytes / networkSpeed).round());
sendPort.send((
'progressUpdate',
task,
progress,
expectedFileSize,
networkSpeed,
timeRemaining,
));
} else {
// no download speed or time remaining
sendPort.send((
'progressUpdate',
task,
progress,
expectedFileSize,
-1.0,
const Duration(seconds: -1),
));
}
}
}
/// Returns a [task] that may be modified through callbacks
///
/// Callbacks would be attached to the task via its [Task.options] property, and if
/// present will be invoked by starting a taskDispatcher on a background isolate, then
/// sending the callback request via the MethodChannel
Future<Task> getModifiedTask(Task task) async {
Task? authTask;
final auth = task.options?.auth;
if (auth != null) {
// refresh token if needed
if (auth.isTokenExpired() && auth.onAuthCallback != null) {
authTask = await auth.onAuthCallback?.call(task);
}
authTask ??= task; // either original or newly authorized
final newAuth = authTask.options?.auth;
if (newAuth == null) {
// no auth object on authorized task
return authTask;
}
// insert query parameters and headers
final uri = newAuth.addOrUpdateQueryParams(
url: authTask.url,
queryParams: newAuth.getAccessQueryParams(),
);
final headers = {...authTask.headers, ...newAuth.getAccessHeaders()};
authTask = authTask.copyWith(url: uri.toString(), headers: headers);
}
authTask = authTask ??= task;
final modifiedTask = await authTask.options?.onTaskStartCallBack?.call(
authTask,
);
return modifiedTask ?? authTask;
}
// The following functions are related to multipart uploads and are
// by and large copied from the dart:http package. Similar implementations
// in Kotlin and Swift are translations of the same code
/// Returns the multipart entry for one field name/value pair
String fieldEntry(String name, String value) =>
'--$boundary$lineFeed${headerForField(name, value)}$value$lineFeed';
/// Returns the header string for a field.
///
/// The return value is guaranteed to contain only ASCII characters.
String headerForField(String name, String value) {
var header = 'content-disposition: form-data; name="${browserEncode(name)}"';
if (isJsonString(value)) {
header =
'$header\r\n'
'content-type: application/json; charset=utf-8';
} else if (!isPlainAscii(value)) {
header =
'$header\r\n'
'content-type: text/plain; charset=utf-8\r\n'
'content-transfer-encoding: binary';
}
return '$header\r\n\r\n';
}
/// A regular expression that matches strings that are composed entirely of
/// ASCII-compatible characters.
final _asciiOnly = RegExp(r'^[\x00-\x7F]+$');
/// A regular expression that matches strings that start and end with JSON object
/// or JSON array characters, and that therefore are presumed to be in JSON
/// format
final _jsonString = RegExp(r'^\s*(\{.*\}|\[.*\])\s*$');
final _newlineRegExp = RegExp(r'\r\n|\r|\n');
/// Returns whether [string] is composed entirely of ASCII-compatible
/// characters.
bool isPlainAscii(String string) => _asciiOnly.hasMatch(string);
/// Returns whether [string] is a JSON formatted string, based on simple
/// test of start and end characters {} or []
bool isJsonString(String string) => _jsonString.hasMatch(string);
/// Encode [value] in the same way browsers do.
String browserEncode(String value) =>
// http://tools.ietf.org/html/rfc2388 mandates some complex encodings for
// field names and file names, but in practice user agents seem not to
// follow this at all. Instead, they URL-encode `\r`, `\n`, and `\r\n` as
// `\r\n`; URL-encode `"`; and do nothing else (even for `%` or non-ASCII
// characters). We follow their behavior.
value.replaceAll(_newlineRegExp, '%0D%0A').replaceAll('"', '%22');
/// Returns the length of the [string] in bytes when utf-8 encoded
int lengthInBytes(String string) => utf8.encode(string).length;
/// Log an error for this task
void logError(Task task, String error) {
log.fine('Error for taskId ${task.taskId}: $error');
}
/// Set the [taskException] variable based on error e
void setTaskError(dynamic e) {
taskException = switch (e) {
HttpException() ||
TimeoutException() => TaskConnectionException(e.toString()),
IOException() => TaskFileSystemException(e.toString()),
TaskException() => e,
_ => TaskException(e.toString()),
};
}
/// Return the response's content as a String, or null if unable
Future<String?> responseContent(http.StreamedResponse response) {
try {
return response.stream.bytesToString();
} catch (e) {
log.fine(
'Could not read response content from httpResponseCode ${response.statusCode}: $e',
);
return Future.value(null);
}
}
/// Returns true if [currentProgress] > [lastProgressUpdate] + 2% and
/// [now] > [nextProgressUpdateTime], or if there was progress and
/// [now] > [nextProgressUpdateTime] + 2 seconds
bool shouldSendProgressUpdate(double currentProgress, DateTime now) =>
(currentProgress - lastProgressUpdate > 0.02 &&
now.isAfter(nextProgressUpdateTime)) ||
(currentProgress > lastProgressUpdate &&
now.isAfter(nextProgressUpdateTime.add(const Duration(seconds: 2))));
@@ -0,0 +1,454 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
import 'dart:math';
import 'package:collection/collection.dart';
import '../chunk.dart';
import '../exceptions.dart';
import '../models.dart';
import '../task.dart';
import '../utils.dart';
import 'desktop_downloader.dart';
import 'download_isolate.dart';
import 'isolate.dart';
/// A [ParallelDownloadTask] pings the server to get the content-length of the
/// download, then creates a list of [Chunk]s, each representing a portion
/// of the download. Each chunk-task has its group set to 'chunk' and
/// has the taskId of the parent [ParallelDownloadTask] in its
/// [Task.metaData] field.
/// The isolate sends 'enqueue' messages back to the [DesktopDownloader] to
/// start each chunk-task, just like any other download task.
/// Messages with group 'chunk' are intercepted in the [DesktopDownloader],
/// where the sendPort for the isolate running the parent task is
/// looked up, and the update is sent to the isolate via that sendPort.
/// In the isolate, the update is processed and the new status/progress
/// of the [ParallelDownloadTask] is determined. If the status/progress has
/// changed, an update is sent and the status is processed (e.g., a complete
/// status triggers the piecing together of the downloaded file from
/// its chunk pieces).
///
/// Similarly, pause and cancel commands are sent to all chunk tasks before
/// updating the status of the parent [ParallelDownloadTask]
late ParallelDownloadTask parentTask;
var chunks = <Chunk>[]; // chunks associated with this download
var lastTaskStatus = TaskStatus.running;
Completer<TaskStatusUpdate> parallelTaskStatusUpdateCompleter = Completer();
var parallelDownloadContentLength = -1;
/// Execute the parallel download task
///
/// Sends updates via the [sendPort] and can be commanded to cancel/pause via
/// the [messagesToIsolate] queue.
///
/// If [isResume] is false, we create [Chunk]s and enqueue each of its tasks
/// by sending a message to the [DesktopDownloader], then wait for the
/// completion of [parallelTaskStatusUpdateCompleter]
///
/// If [isResume] is true, [resumeData] contains the json encoded [chunks] list,
/// and the associated chunk [DownloadTask] tasks will be started by the
/// [DesktopDownloader], so we just wait for completion of
/// [parallelTaskStatusUpdateCompleter]
///
/// Incoming messages from the [DesktopDownloader] are received in the
/// [listenToIncomingMessages] function
Future<void> doParallelDownloadTask(
ParallelDownloadTask task,
ResumeData? resumeData,
bool isResume,
Duration requestTimeout,
SendPort sendPort,
) async {
parentTask = task;
if (!isResume) {
// start the download by creating [Chunk]s and enqueuing chunk tasks
final response = await DesktopDownloader.httpClientForUrl(task.url)
.head(Uri.parse(task.url), headers: task.headers)
.timeout(requestTimeout);
responseHeaders = response.headers;
responseStatusCode = response.statusCode;
if ([200, 201, 202, 203, 204, 205, 206].contains(response.statusCode)) {
// get suggested filename if needed, and change task and parentTask
if (!task.hasFilename) {
task = (await taskWithSuggestedFilename(
task,
response.headers,
true,
)) as ParallelDownloadTask;
parentTask = task;
log.finest(
'Suggested filename for taskId ${task.taskId}: ${task.filename}',
);
}
extractContentType(response.headers);
chunks = createChunks(task, response.headers);
for (final chunk in chunks) {
// Ask main isolate to enqueue the child task. Updates related to the child
// will be sent to this isolate (the child's metaData contains the parent taskId).
sendPort.send(('enqueueChild', chunk.task));
}
// wait for all chunk tasks to complete
final statusUpdate = await parallelTaskStatusUpdateCompleter.future;
processStatusUpdateInIsolate(task, statusUpdate.status, sendPort);
} else {
log.fine(
'TaskId ${task.taskId}: Invalid server response code ${response.statusCode}',
);
// not an OK response
responseBody = response.body;
if (response.statusCode == 404) {
processStatusUpdateInIsolate(task, TaskStatus.notFound, sendPort);
} else {
taskException = TaskHttpException(
responseBody?.isNotEmpty == true
? responseBody!
: response.reasonPhrase ?? 'Invalid HTTP Request',
response.statusCode,
);
processStatusUpdateInIsolate(task, TaskStatus.failed, sendPort);
}
}
} else {
// resume: reconstruct [chunks] and wait for all chunk tasks to complete
chunks = List.from(
jsonDecode(resumeData!.data, reviver: Chunk.listReviver),
);
parallelDownloadContentLength = chunks.fold(
0,
(previousValue, chunk) =>
previousValue + chunk.toByte - chunk.fromByte + 1,
);
final statusUpdate = await parallelTaskStatusUpdateCompleter.future;
processStatusUpdateInIsolate(task, statusUpdate.status, sendPort);
}
}
/// Process incoming [update] for a chunk, within the [ParallelDownloadTask]
/// represented by [task]
Future<void> chunkStatusUpdate(
Task task,
TaskStatusUpdate update,
SendPort sendPort,
) async {
final chunkTask = update.task;
// first check for fail -> retry
if (update.status == TaskStatus.failed && chunkTask.retriesRemaining > 0) {
chunkTask.decreaseRetriesRemaining();
final waitTime = Duration(
seconds: 2 << min(chunkTask.retries - chunkTask.retriesRemaining - 1, 8),
);
log.finer(
'Chunk task with taskId ${chunkTask.taskId} failed, waiting ${waitTime.inSeconds}'
' seconds before retrying. ${chunkTask.retriesRemaining}'
' retries remaining',
);
Future.delayed(waitTime, () async {
// after delay, resume or enqueue task again if it's still waiting
sendPort.send(('enqueueChild', chunkTask));
});
} else {
// no retry
final newStatusUpdate = updateChunkStatus(update);
switch (newStatusUpdate) {
case TaskStatus.complete:
final result = await stitchChunks();
parallelTaskStatusUpdateCompleter.complete(
TaskStatusUpdate(
task,
result,
null,
responseBody,
responseHeaders,
responseStatusCode,
),
);
case TaskStatus.failed:
taskException = update.exception;
responseBody = update.responseBody;
cancelAllChunkTasks(sendPort);
parallelTaskStatusUpdateCompleter.complete(
TaskStatusUpdate(
task,
TaskStatus.failed,
taskException,
responseBody,
responseHeaders,
responseStatusCode,
),
);
case TaskStatus.notFound:
responseBody = update.responseBody;
cancelAllChunkTasks(sendPort);
parallelTaskStatusUpdateCompleter.complete(
TaskStatusUpdate(
task,
TaskStatus.notFound,
null,
responseBody,
responseHeaders,
responseStatusCode,
),
);
default:
// ignore all other status updates, including null
break;
}
}
}
/// Update the status for this chunk, and return the status for the parent task
/// as derived from the sum of the child tasks, or null if undefined
///
/// The updates are received from the [DeskTopDownloader], which intercepts
/// status updates for the [chunkGroup]. Not all regular statuses are passed on
TaskStatus? updateChunkStatus(TaskStatusUpdate update) {
final chunk = chunks.firstWhereOrNull((chunk) => chunk.task == update.task);
if (chunk == null) {
return null; // chunk is not part of this parent task
}
chunk.status = update.status;
final newStatusUpdate = parentTaskStatus();
if ((newStatusUpdate != null && newStatusUpdate != lastTaskStatus)) {
lastTaskStatus = newStatusUpdate;
return newStatusUpdate;
}
return null;
}
/// Returns the [TaskStatus] for the parent of this chunk, as derived from
/// the 'sum' of the child tasks, or null if undetermined
///
/// The updates are received from the [DeskTopDownloader], which intercepts
/// status updates for the [chunkGroup]
TaskStatus? parentTaskStatus() {
final failed = chunks.firstWhereOrNull(
(chunk) => chunk.status == TaskStatus.failed,
);
if (failed != null) {
return TaskStatus.failed;
}
final notFound = chunks.firstWhereOrNull(
(chunk) => chunk.status == TaskStatus.notFound,
);
if (notFound != null) {
return TaskStatus.notFound;
}
final allComplete = chunks.every(
(chunk) => chunk.status == TaskStatus.complete,
);
if (allComplete) {
return TaskStatus.complete;
}
return null;
}
/// Process incoming [update] for a chunk, within the [ParallelDownloadTask]
/// represented by [task]
void chunkProgressUpdate(
Task task,
TaskProgressUpdate update,
SendPort sendPort,
) {
// update of child task of a [ParallelDownloadTask], only for regular
// progress updates
final now = DateTime.now();
if (update.progress > 0 && update.progress < 1) {
final parentProgressUpdate = updateChunkProgress(update);
if (parentProgressUpdate != null &&
shouldSendProgressUpdate(parentProgressUpdate, now)) {
processProgressUpdateInIsolate(
task,
parentProgressUpdate,
sendPort,
parallelDownloadContentLength,
);
lastProgressUpdate = parentProgressUpdate;
nextProgressUpdateTime = now.add(const Duration(milliseconds: 500));
}
}
}
/// Update the progress for this chunk, and return the progress for the parent
/// task as derived from the sum of the child tasks, or null if undefined
///
/// The updates are received from the [DeskTopDownloader], which intercepts
/// progress updates for the [chunkGroup].
/// Only true progress updates (in range 0-1) are passed on to this method
double? updateChunkProgress(TaskProgressUpdate update) {
final chunk = chunks.firstWhereOrNull((chunk) => chunk.task == update.task);
if (chunk == null) {
return null; // chunk is not part of this parent task
}
chunk.progress = update.progress;
return parentTaskProgress();
}
/// Returns the progress for the parent of this chunk, as derived
/// from the 'sum' of the child tasks
///
/// The updates are received from the [DeskTopDownloader], which intercepts
/// progress updates for the [chunkGroup].
/// Only true progress updates (in range 0-1) are passed on to this method,
/// so we just calculate the average progress
double parentTaskProgress() {
final avgProgress =
chunks.fold(
0.0,
(previousValue, chunk) => previousValue + chunk.progress,
) /
chunks.length;
return avgProgress;
}
/// Cancel this [ParallelDownloadTask]
void cancelParallelDownloadTask(ParallelDownloadTask task, SendPort sendPort) {
cancelAllChunkTasks(sendPort);
parallelTaskStatusUpdateCompleter.complete(
TaskStatusUpdate(task, TaskStatus.canceled),
);
}
/// Cancel the tasks associated with each chunk
///
/// Accomplished by sending list of taskIds to cancel to the
/// [DesktopDownloader]
void cancelAllChunkTasks(SendPort sendPort) {
sendPort.send((
'cancelTasksWithId',
chunks.map((e) => e.task.taskId).toList(),
));
}
/// Pause this [ParallelDownloadTask]
///
/// Because each [Chunk] is a [DownloadTask], each is paused
/// and generates its own [ResumeData].
/// [ResumeData] for a [ParallelDownloadTask] is the list of [Chunk]s,
/// including their status and progress, json encoded.
/// To resume, we need to recreate the list of [Chunk]s and pass this with
/// the resumed [ParallelDownloadTask], then resume each of the
/// [DownloadTask]s. This is done in [DesktopDownloader.resume]
void pauseParallelDownloadTask(ParallelDownloadTask task, SendPort sendPort) {
pauseAllChunkTasks(sendPort);
sendPort.send(('resumeData', jsonEncode(chunks), -1, null));
parallelTaskStatusUpdateCompleter.complete(
TaskStatusUpdate(task, TaskStatus.paused),
);
}
/// Pause the tasks associated with each chunk
///
/// Accomplished by sending list of tasks to pause to the
/// [DesktopDownloader]
void pauseAllChunkTasks(SendPort sendPort) {
sendPort.send(('pauseTasks', chunks.map((e) => e.task).toList()));
}
/// Stitch all chunks together into one file, per the [parentTask]
Future<TaskStatus> stitchChunks() async {
IOSink? outStream;
StreamSubscription? subscription;
try {
final outFile = File(await parentTask.filePath());
if (await outFile.exists()) {
await outFile.delete();
}
outStream = outFile.openWrite();
for (final chunk in chunks.sorted((a, b) => a.fromByte - b.fromByte)) {
final inFile = File(await chunk.task.filePath());
if (!await inFile.exists()) {
throw const FileSystemException('Missing chunk file');
}
final inStream = inFile.openRead();
final doneCompleter = Completer<bool>();
subscription = inStream.listen(
(bytes) {
outStream?.add(bytes);
},
onDone: () => doneCompleter.complete(true),
onError: (error) {
logError(parentTask, e.toString());
setTaskError(error);
doneCompleter.complete(false);
},
);
final success = await doneCompleter.future;
if (!success) {
return TaskStatus.failed;
}
subscription.cancel();
await inFile.delete();
}
await outStream.flush();
} catch (e) {
logError(parentTask, e.toString());
setTaskError(e);
return TaskStatus.failed;
} finally {
await outStream?.close();
subscription?.cancel();
for (final chunk in chunks) {
try {
final file = File(await chunk.task.filePath());
await file.delete();
} on FileSystemException {
// ignore
}
}
}
return TaskStatus.complete;
}
/// Returns a list of chunk information for this task, and sets
/// [parallelDownloadContentLength] to the total length of the download
///
/// Throws a StateError if any information is missing, which should lead
/// to a failure of the [ParallelDownloadTask]
List<Chunk> createChunks(
ParallelDownloadTask task,
Map<String, String> headers,
) {
try {
final numChunks = task.urls.length * task.chunks;
final contentLength = getContentLength(headers, task);
if (contentLength <= 0) {
throw StateError(
'Server does not provide content length - cannot chunk download. '
'If you know the length, set Range or Known-Content-Length header',
);
}
parallelDownloadContentLength = contentLength;
try {
headers.entries.firstWhere(
(element) =>
element.key.toLowerCase() == 'accept-ranges' &&
element.value == 'bytes',
);
} on StateError {
throw StateError('Server does not accept ranges - cannot chunk download');
}
final chunkSize = (contentLength / numChunks).ceil();
return [
for (var i = 0; i < numChunks; i++)
Chunk(
parentTask: task,
url: task.urls[i % task.urls.length],
filename: Random().nextInt(1 << 32).toString(),
fromByte: i * chunkSize,
toByte: min(i * chunkSize + chunkSize - 1, contentLength - 1),
),
];
} on StateError {
throw StateError(
'Server does not provide content length - cannot chunk download. '
'If you know the length, set Range or Known-Content-Length header',
);
}
}
@@ -0,0 +1,313 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
import 'package:http/http.dart' as http;
import 'package:mime/mime.dart';
import 'package:path/path.dart' as p;
import '../exceptions.dart';
import '../models.dart';
import '../task.dart';
import 'desktop_downloader.dart';
import 'isolate.dart';
const boundary = '-----background_downloader-akjhfw281onqciyhnIk';
const lineFeed = '\r\n';
/// Do the binary or multi-part upload task
///
/// Sends updates via the [sendPort] and can be commanded to cancel via
/// the [messagesToIsolate] queue
Future<void> doUploadTask(UploadTask task, SendPort sendPort) async {
final (updatedTask, resultStatus) = task.post == 'binary'
? await binaryUpload(task, sendPort)
: await multipartUpload(task, sendPort);
processStatusUpdateInIsolate(updatedTask, resultStatus, sendPort);
}
/// Do the binary upload and return the TaskStatus
///
/// Content-Disposition header will be:
/// - set to 'attachment = "filename"' if the task.headers field does not contain
/// an entry for 'Content-Disposition'
/// - not set at all (i.e. omitted) if the task.headers field contains an entry
/// for 'Content-Disposition' with the value '' (an empty string)
/// - set to the value of task.headers['Content-Disposition'] in all other cases
///
/// The mime-type will be set to [Task.mimeType]
///
/// Sends updates via the [sendPort] and can be commanded to cancel via
/// the [messagesToIsolate] queue
Future<(Task, TaskStatus)> binaryUpload(
UploadTask task,
SendPort sendPort,
) async {
final filePath = await task.filePath();
final inFile = File(filePath);
if (!inFile.existsSync()) {
final message = 'File to upload does not exist: $filePath';
logError(task, message);
taskException = TaskFileSystemException(message);
return (task, TaskStatus.failed);
}
final fileSize = inFile.lengthSync();
if (fileSize == 0) {
final message = 'File $filePath has 0 length';
logError(task, message);
taskException = TaskFileSystemException(message);
return (task, TaskStatus.failed);
}
var resultStatus = TaskStatus.failed;
try {
// Extract Range header information, if present, for partial upload
int start = 0;
int end = fileSize - 1; // Default to the whole file
if (task.headers.containsKey('Range')) {
final rangeHeader = task.headers['Range']!;
final match = RegExp(r'bytes=(\d+)-(\d*)').firstMatch(rangeHeader);
if (match != null) {
start = int.parse(match.group(1)!);
if (match.group(2)!.isNotEmpty) {
end = int.parse(match.group(2)!);
}
} else {
final message = 'Invalid Range header $rangeHeader';
logError(task, message);
taskException = TaskException(message);
return (task, TaskStatus.failed);
}
task.headers.remove('Range'); // not passed on to server
}
if (task case UriUploadTask(fileUri: final fileUri)
when fileUri != null && task.filename.isEmpty) {
// for UriTasks without a filename, derive it from the Uri
task = task.copyWith(filename: fileUri.pathSegments.last);
}
final contentLength = end - start + 1;
final client = DesktopDownloader.httpClientForUrl(task.url);
final request = http.StreamedRequest(
task.httpRequestMethod,
Uri.parse(task.url),
);
request.maxRedirects = defaultMaxRedirects;
request.headers.addAll(task.headers);
request.contentLength = contentLength;
request.headers['Content-Type'] = task.mimeType;
final taskContentDisposition =
task.headers['Content-Disposition'] ??
task.headers['content-disposition'];
if (taskContentDisposition != '') {
request.headers['Content-Disposition'] =
taskContentDisposition ??
'attachment; filename="${Uri.encodeComponent(task.filename)}"';
} else {
request.headers.remove('Content-Disposition');
}
request.persistentConnection = false;
// initiate the request and handle completion async
final requestCompleter = Completer();
var transferBytesResult = TaskStatus.failed;
client.send(request).then((response) async {
// request completed, so send status update and finish
resultStatus =
transferBytesResult == TaskStatus.complete &&
!okResponses.contains(response.statusCode)
? TaskStatus.failed
: transferBytesResult;
responseBody = await responseContent(response);
responseHeaders = response.headers;
responseStatusCode = response.statusCode;
taskException ??= TaskHttpException(
responseBody?.isNotEmpty == true
? responseBody!
: response.reasonPhrase ?? 'Invalid HTTP response',
response.statusCode,
);
if (response.statusCode == 404) {
resultStatus = TaskStatus.notFound;
}
requestCompleter.complete();
});
// send the bytes to the request sink
final inStream = inFile.openRead(start, end + 1);
transferBytesResult = await transferBytes(
inStream,
request.sink,
fileSize,
task,
sendPort,
);
request.sink.close(); // triggers request completion, handled above
if (isCanceled) {
// cancellation overrides other results
resultStatus = TaskStatus.canceled;
} else {
await requestCompleter.future; // wait for request to complete
}
} catch (e) {
resultStatus = TaskStatus.failed;
setTaskError(e);
}
return (task, resultStatus);
}
/// Do the multipart upload and return the TaskStatus
///
/// Sends updates via the [sendPort] and can be commanded to cancel via
/// the [messagesToIsolate] queue
Future<(Task, TaskStatus)> multipartUpload(
UploadTask task,
SendPort sendPort,
) async {
// field portion of the multipart, all in one string
// multiple values should be encoded as '"value1", "value2", ...'
final multiValueRegEx = RegExp(r'^(?:"[^"]+"\s*,\s*)+"[^"]+"$');
var fieldsString = '';
for (final entry in task.fields.entries) {
if (multiValueRegEx.hasMatch(entry.value)) {
// extract multiple values from entry.value
for (final match in RegExp(r'"([^"]+)"').allMatches(entry.value)) {
fieldsString += fieldEntry(entry.key, match.group(1) ?? 'error');
}
} else {
fieldsString += fieldEntry(
entry.key,
entry.value,
); // single value for key
}
}
// File portion of the multi-part
// Assumes list of files. If only one file, that becomes a list of length one.
// For each file, determine contentDispositionString, contentTypeString
// and file length, so that we can calculate total size of upload
const separator = '$lineFeed--$boundary$lineFeed'; // between files
const terminator = '$lineFeed--$boundary--$lineFeed'; // after last file
final filesData = (task is MultiUploadTask)
? await task
.extractFilesData() // MultiUpload case
: [
(task.fileField, await task.filePath(), task.mimeType),
]; // one file Upload case
final contentDispositionStrings = <String>[];
final contentTypeStrings = <String>[];
final fileLengths = <int>[];
for (final (fileField, path, mimeType) in filesData) {
final file = File(path);
if (!await file.exists()) {
logError(task, 'File to upload does not exist: $path');
taskException = TaskFileSystemException(
'File to upload does not exist: $path',
);
return (task, TaskStatus.failed);
}
final resolvedMimeType = mimeType.isEmpty ? lookupMimeType(path) : mimeType;
final derivedFilename = p.basename(file.path);
if (filesData.length == 1) {
// only for single file uploads do we set the task's filename property
if (task case UriUploadTask(fileUri: final fileUri)
when fileUri != null) {
task = task.copyWith(filename: fileUri.pathSegments.last);
} else {
task = task.copyWith(filename: derivedFilename);
}
}
contentDispositionStrings.add(
'Content-Disposition: form-data; name="${browserEncode(fileField)}"; '
'filename="${browserEncode(derivedFilename)}"$lineFeed',
);
contentTypeStrings.add('Content-Type: $resolvedMimeType$lineFeed$lineFeed');
fileLengths.add(file.lengthSync());
}
final fileDataLength =
contentDispositionStrings.fold<int>(
0,
(sum, string) => sum + lengthInBytes(string),
) +
contentTypeStrings.fold<int>(0, (sum, string) => sum + string.length) +
fileLengths.fold<int>(0, (sum, length) => sum + length) +
separator.length * contentDispositionStrings.length +
2;
final contentLength =
lengthInBytes(fieldsString) +
'--$boundary$lineFeed'.length +
fileDataLength;
var resultStatus = TaskStatus.failed;
try {
// setup the connection
final client = DesktopDownloader.httpClientForUrl(task.url);
final request = http.StreamedRequest(
task.httpRequestMethod,
Uri.parse(task.url),
);
request.maxRedirects = defaultMaxRedirects;
request.contentLength = contentLength;
request.headers.addAll(task.headers);
request.headers.addAll({
'Content-Type': 'multipart/form-data; boundary=$boundary',
'Accept-Charset': 'UTF-8',
'Connection': 'Keep-Alive',
'Cache-Control': 'no-cache',
});
request.persistentConnection = false;
// initiate the request and handle completion async
final requestCompleter = Completer();
var transferBytesResult = TaskStatus.failed;
client.send(request).then((response) async {
// request completed, so send status update and finish
resultStatus =
transferBytesResult == TaskStatus.complete &&
!okResponses.contains(response.statusCode)
? TaskStatus.failed
: transferBytesResult;
responseBody = await responseContent(response);
responseHeaders = response.headers;
responseStatusCode = response.statusCode;
taskException ??= TaskHttpException(
responseBody?.isNotEmpty == true
? responseBody!
: response.reasonPhrase ?? 'Invalid HTTP response',
response.statusCode,
);
if (response.statusCode == 404) {
resultStatus = TaskStatus.notFound;
}
requestCompleter.complete();
});
// write fields
request.sink.add(utf8.encode('$fieldsString--$boundary$lineFeed'));
// write each file
for (final (index, fileData) in filesData.indexed) {
request.sink.add(utf8.encode(contentDispositionStrings[index]));
request.sink.add(utf8.encode(contentTypeStrings[index]));
// send the bytes to the request sink
final inStream = File(fileData.$2).openRead();
transferBytesResult = await transferBytes(
inStream,
request.sink,
contentLength,
task,
sendPort,
);
if (transferBytesResult != TaskStatus.complete || isCanceled) {
break;
} else {
request.sink.add(
utf8.encode(fileData == filesData.last ? terminator : separator),
);
}
}
request.sink.close(); // triggers request completion, handled above
await requestCompleter.future; // wait for request to complete
} catch (e) {
resultStatus = TaskStatus.failed;
setTaskError(e);
}
if (isCanceled) {
// cancellation overrides other results
resultStatus = TaskStatus.canceled;
}
return (task, resultStatus);
}
@@ -0,0 +1,105 @@
import 'dart:convert';
const _exceptions = {
'TaskException': TaskException.new,
'TaskFileSystemException': TaskFileSystemException.new,
'TaskUrlException': TaskUrlException.new,
'TaskConnectionException': TaskConnectionException.new,
'TaskResumeException': TaskResumeException.new,
'TaskHttpException': TaskHttpException.new,
};
/// Contains Exception information associated with a failed [Task]
///
/// The [exceptionType] categorizes and describes the exception
/// The [description] is typically taken from the platform-generated
/// exception message, or from the plugin. The localization is undefined
/// For the [TaskHttpException], the [httpResponseCode] is only valid if >0
/// and may offer details about the nature of the error
base class TaskException(final String description) implements Exception {
String get exceptionType => 'TaskException';
/// Create object from [json]
factory TaskException.fromJson(Map<String, dynamic> json) {
final typeString = json['type'] as String? ?? 'TaskException';
final exceptionType = _exceptions[typeString];
final description = json['description'] as String? ?? '';
if (exceptionType != null) {
final httpResponseCode =
(json['httpResponseCode'] as num?)?.toInt() ?? -1;
return typeString != 'TaskHttpException'
? exceptionType(description)
: exceptionType(description, httpResponseCode);
}
return TaskException('Unknown');
}
/// Create object from String description of the type, and parameters
factory TaskException.fromTypeString(
String typeString,
String description, [
int httpResponseCode = -1,
]) {
final exceptionType = _exceptions[typeString] ?? TaskException.new;
return typeString != 'TaskHttpException'
? exceptionType(description)
: exceptionType(description, httpResponseCode);
}
/// Return JSON Map representing object
Map<String, dynamic> toJson() => {
'type': exceptionType,
'description': description,
};
/// Return JSON String representing object
String toJsonString() => jsonEncode(toJson());
@override
String toString() => '$exceptionType: $description';
}
/// Exception related to the filesystem, e.g. insufficient space
/// or file not found
final class TaskFileSystemException(super.description) extends TaskException {
@override
String get exceptionType => 'TaskFileSystemException';
}
/// Exception related to the url, eg malformed
final class TaskUrlException(super.description) extends TaskException {
@override
String get exceptionType => 'TaskUrlException';
}
/// Exception related to the connection, e.g. socket exception
/// or request timeout
final class TaskConnectionException(super.description) extends TaskException {
@override
String get exceptionType => 'TaskConnectionException';
}
/// Exception related to an attempt to resume a task, e.g.
/// the temp filename no longer exists, or eTag has changed
final class TaskResumeException(super.description) extends TaskException {
@override
String get exceptionType => 'TaskResumeException';
}
/// Exception related to the HTTP response, e.g. a 403
/// response code
final class TaskHttpException(super.description, final int httpResponseCode)
extends TaskException {
@override
String get exceptionType => 'TaskHttpException';
@override
Map<String, dynamic> toJson() => {
...super.toJson(),
'httpResponseCode': httpResponseCode,
};
@override
String toString() =>
'$exceptionType, response code $httpResponseCode: $description';
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,243 @@
import 'dart:async';
import 'dart:convert';
import 'dart:isolate';
import 'base_downloader.dart';
import 'models.dart';
import 'task.dart';
/// Commands to be executed by the background isolate
sealed class JsonCommand {
final int id;
const JsonCommand(this.id);
}
class _TaskFromJson extends JsonCommand {
final String jsonString;
const _TaskFromJson(super.id, this.jsonString);
}
class _DownloadTaskListFromJson extends JsonCommand {
final String jsonString;
const _DownloadTaskListFromJson(super.id, this.jsonString);
}
class _TaskListFromListStrings extends JsonCommand {
final List<dynamic> jsonStrings;
const _TaskListFromListStrings(super.id, this.jsonStrings);
}
class _TaskAndNotificationConfigJsonStrings extends JsonCommand {
final List<Task> tasks;
final Set<TaskNotificationConfig> notificationConfigs;
const _TaskAndNotificationConfigJsonStrings(
super.id,
this.tasks,
this.notificationConfigs,
);
}
/// Singleton object that manages a background isolate for JSON encoding/decoding
class JsonProcessor {
static final JsonProcessor _instance = JsonProcessor._internal();
factory JsonProcessor() => _instance;
JsonProcessor._internal();
Isolate? _isolate;
SendPort? _sendPort;
final Map<int, Completer<dynamic>> _pendingCompleters = {};
Timer? _shutdownTimer;
int _nextId = 0;
bool _isStarting = false;
Completer<void> _startCompleter = Completer<void>();
/// Public API
Future<Task> decodeTask(String jsonString) async =>
await _process<Task>((id) => _TaskFromJson(id, jsonString));
Future<List<DownloadTask>> decodeDownloadTaskList(String jsonString) async =>
await _process<List<DownloadTask>>(
(id) => _DownloadTaskListFromJson(id, jsonString),
);
Future<List<Task>> decodeTaskList(List<dynamic> jsonStrings) async =>
await _process<List<Task>>(
(id) => _TaskListFromListStrings(id, jsonStrings),
);
Future<(String, String)> encodeTaskAndNotificationConfig(
Iterable<Task> tasks,
Set<TaskNotificationConfig> notificationConfigs,
) async {
// Convert Iterable to List to ensure it's sendable and fixed
final taskList = tasks.toList();
return await _process<(String, String)>(
(id) => _TaskAndNotificationConfigJsonStrings(
id,
taskList,
notificationConfigs,
),
);
}
/// Internal processing logic
Future<T> _process<T>(JsonCommand Function(int id) commandBuilder) async {
await _ensureStarted();
_resetShutdownTimer();
final id = _nextId++;
final completer = Completer<T>();
_pendingCompleters[id] = completer;
final command = commandBuilder(id);
_sendPort!.send(command);
try {
final result = await completer.future;
return result;
} catch (e) {
rethrow;
} finally {
_resetShutdownTimer(); // Reset timer on completion too
}
}
Future<void> _ensureStarted() async {
if (_isolate != null && _sendPort != null) return;
if (_isStarting) {
await _startCompleter.future;
return;
}
_isStarting = true;
_startCompleter = Completer<void>();
try {
final receivePort = ReceivePort();
_isolate = await Isolate.spawn(_isolateMain, receivePort.sendPort);
// Wait for the isolate to send its SendPort
_sendPort = await receivePort.first as SendPort;
// Create a new receive port for responses
final responsePort = ReceivePort();
_sendPort!.send(responsePort.sendPort);
responsePort.listen(_handleResponse);
if (!_startCompleter.isCompleted) {
_startCompleter.complete();
}
} catch (e) {
_isolate?.kill();
_isolate = null;
_sendPort = null;
_isStarting = false;
if (!_startCompleter.isCompleted) {
_startCompleter.completeError(e);
}
rethrow;
} finally {
_isStarting = false;
}
}
void _handleResponse(dynamic message) {
if (message is _JsonResponse) {
final completer = _pendingCompleters.remove(message.id);
if (completer != null) {
if (message.error != null) {
completer.completeError(message.error!);
} else {
completer.complete(message.result);
}
}
}
}
void _resetShutdownTimer() {
_shutdownTimer?.cancel();
_shutdownTimer = Timer(const Duration(minutes: 1), _shutdown);
}
void _shutdown() {
if (_pendingCompleters.isNotEmpty) {
// Defer shutdown while requests are still in flight
_resetShutdownTimer();
return;
}
_isolate?.kill();
_isolate = null;
_sendPort = null;
_shutdownTimer?.cancel();
_shutdownTimer = null;
}
}
class _JsonResponse {
final int id;
final dynamic result;
final Object? error;
_JsonResponse(this.id, this.result, this.error);
}
void _isolateMain(SendPort mainSendPort) {
final receivePort = ReceivePort();
mainSendPort.send(receivePort.sendPort);
SendPort? replyPort;
receivePort.listen((message) async {
if (message is SendPort) {
replyPort = message;
} else if (message is JsonCommand) {
if (replyPort == null) {
return; // Should not happen if protocol is followed
}
try {
final result = await _executeCommand(message);
replyPort!.send(_JsonResponse(message.id, result, null));
} catch (e) {
replyPort!.send(_JsonResponse(message.id, null, e));
}
}
});
}
Future<dynamic> _executeCommand(JsonCommand command) async {
switch (command) {
case final _TaskFromJson c:
return Task.createFromJson(jsonDecode(c.jsonString));
case final _DownloadTaskListFromJson c:
return (jsonDecode(c.jsonString) as List)
.map((e) => Task.createFromJson(e as Map<String, dynamic>))
.cast<DownloadTask>()
.toList();
case final _TaskListFromListStrings c:
return c.jsonStrings
.map((e) => Task.createFromJson(jsonDecode(e as String)))
.toList();
case final _TaskAndNotificationConfigJsonStrings c:
final tasksJsonString = jsonEncode(c.tasks);
final configs = c.tasks
.map(
(task) => BaseDownloader.notificationConfigForTaskUsingConfigSet(
task,
c.notificationConfigs,
),
)
.toList();
final notificationConfigsJsonString = jsonEncode(configs);
return (tasksJsonString, notificationConfigsJsonString);
}
}
@@ -0,0 +1,100 @@
part of 'localstore.dart';
/// A [CollectionRef] object can be used for adding documents, getting
/// [DocumentRef]s, and querying for documents.
final class CollectionRef implements CollectionRefImpl {
String _id;
/// A string representing the path of the referenced document (relative to the
/// root of the database).
String get path => _path;
String _path = '';
DocumentRef? _delegate;
CollectionRef? _parent;
List<List>? _conditions;
static final pathSeparatorRegEx = RegExp(r'[/\\]');
/// The parent [CollectionRef] of this document.
CollectionRef? get parent => _parent;
CollectionRef._(this._id, [this._parent, this._delegate, this._conditions]) {
_path = _buildPath(_parent?.path, _id, _delegate?.id);
}
static final _cache = <String, CollectionRef>{};
/// Returns an instance using the default [CollectionRef].
factory CollectionRef(
String id, [
CollectionRef? parent,
DocumentRef? delegate,
List<List>? conditions,
]) {
final key = _buildPath(parent?.path, id, delegate?.id);
final collectionRef = _cache.putIfAbsent(
key,
() => CollectionRef._(id, parent, delegate, conditions),
);
collectionRef._conditions = conditions;
return collectionRef;
}
static String _buildPath(String? parentPath, String path, String? docId) {
final docPath = ((docId != null && parentPath != null)
? '$docId.collection'
: '');
final pathSep = p.separator;
return '${parentPath ?? ''}$docPath$pathSep$path$pathSep';
}
final _utils = Utils.instance;
@override
Stream<Map<String, dynamic>> get stream => _utils.stream(path, _conditions);
@override
Future<Map<String, dynamic>?> get() async =>
await _utils.get(path, true, _conditions);
@override
DocumentRef doc([String? id]) {
id ??= int.parse(
'${Random().nextInt(1000000000)}${Random().nextInt(1000000000)}',
).toRadixString(35).substring(0, 9);
return DocumentRef(id, this);
}
@override
CollectionRef where(field, {isEqualTo}) {
final conditions = <List>[];
void addCondition(dynamic field, String operator, dynamic value) {
List<dynamic> condition;
condition = <dynamic>[field, operator, value];
conditions.add(condition);
}
if (isEqualTo != null) addCondition(field, '==', isEqualTo);
_conditions = conditions;
return this;
}
@override
Future<void> delete() async {
final docs = await _utils.get(path, true, _conditions);
if (docs != null) {
for (final key in docs.keys) {
final id = key.split(pathSeparatorRegEx).last;
DocumentRef(id, this)._data.clear();
}
}
await _utils.delete(path);
}
}
@@ -0,0 +1,29 @@
part of 'localstore.dart';
/// The interface that other CollectionRef must extend.
abstract class CollectionRefImpl {
/// Returns a `DocumentRef` with the provided id.
///
/// If no [id] is provided, an auto-generated ID is used.
///
/// The unique key generated is prefixed with a client-generated timestamp
/// so that the resulting list will be chronologically-sorted.
DocumentRef doc([String? id]);
/// Notifies of query results at this collection.
Stream<Map<String, dynamic>> get stream;
/// Fetch the documents for this collection
Future<Map<String, dynamic>?> get();
/// Creates and returns a new [CollectionRef] with additional filter on
/// specified [field]. [field] refers to a field in a document.
///
/// `where` is not implemented
CollectionRef where(dynamic field, {dynamic isEqualTo});
/// Delete collection
///
/// All collections and documents in this collection will be deleted.
Future<void> delete();
}
@@ -0,0 +1,66 @@
part of 'localstore.dart';
/// A [DocumentRef] refers to a document location in a [Localstore] database
/// and can be used to write, read, or listen to the location.
///
/// The document at the referenced location may or may not exist.
/// A [DocumentRef] can also be used to create a [CollectionRef]
/// to a subcollection.
final class DocumentRef implements DocumentRefImpl {
String _id;
/// This document's given ID within the collection.
String get id => _id;
CollectionRef? _delegate;
DocumentRef._(this._id, [this._delegate]);
static final _cache = <String, DocumentRef>{};
/// Returns an instance using the default [DocumentRef].
factory DocumentRef(String id, [CollectionRef? delegate]) {
final key = '${delegate?.path ?? ''}$id';
return _cache.putIfAbsent(key, () => DocumentRef._(id, delegate));
}
/// A string representing the path of the referenced document (relative to the
/// root of the database).
String get path => '${_delegate?.path}$id';
final _utils = Utils.instance;
final Map<String, dynamic> _data = {};
@override
Future<dynamic> set(Map<String, dynamic> data, [SetOptions? options]) async {
options ??= SetOptions();
if (options.merge) {
final output = Map<String, dynamic>.from(data);
final Map<String, dynamic>? input = _data[id] ?? {};
output.updateAll((key, value) {
input![key] = value;
});
_data[id] = input;
} else {
_data[id] = data;
}
return await _utils.set(_data[id], path);
}
@override
Future<Map<String, dynamic>?> get() async =>
_data[id] ?? await _utils.get(path);
@override
Future delete() async {
await _utils.delete(path);
_data.remove(id);
}
@override
CollectionRef collection(String id) => CollectionRef(id, _delegate, this);
@override
String toString() => _utils.toString();
}
@@ -0,0 +1,20 @@
part of 'localstore.dart';
/// The interface that other DocumentRef must extend.
abstract class DocumentRefImpl {
/// Gets a [CollectionRef] for the specified Localstore path.
CollectionRef collection(String path);
/// Sets data on the document, overwriting any existing data. If the document
/// does not yet exist, it will be created.
///
/// If [SetOptions] are provided, the data will be merged into an existing
/// document instead of overwriting.
Future<dynamic> set(Map<String, dynamic> data, [SetOptions? options]);
/// Reads the document referenced by this [DocumentRef].
Future<Map<String, dynamic>?> get();
/// Deletes the current document from the collection.
Future delete();
}
@@ -0,0 +1,19 @@
library;
import 'dart:async';
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'dart:math';
import 'utils/html.dart' if (dart.library.io) 'utils/io.dart';
part 'collection_ref.dart';
part 'collection_ref_impl.dart';
part 'document_ref.dart';
part 'document_ref_impl.dart';
part 'set_option.dart';
part 'localstore_base.dart';
part 'localstore_impl.dart';
@@ -0,0 +1,30 @@
part of 'localstore.dart';
/// The entry point for accessing a [Localstore].
///
/// You can get an instance by calling [Localstore.instance], for example:
///
/// ```dart
/// final db = Localstore.instance;
/// ```
final class Localstore implements LocalstoreImpl {
final _databaseDirectory = getApplicationSupportDirectory();
final _delegate = DocumentRef._('');
static final Localstore _localstore = Localstore._();
/// Private initializer
Localstore._();
/// Returns an instance using the default [Localstore].
static Localstore get instance => _localstore;
Future<Directory> get databaseDirectory => _databaseDirectory;
/// Clears the cache - needed only if filesystem has been manipulated directly
void clearCache() {
Utils.instance.clearCache();
}
@override
CollectionRef collection(String path) => CollectionRef(path, null, _delegate);
}
@@ -0,0 +1,7 @@
part of 'localstore.dart';
/// The interface that other Localstore must extend.
abstract class LocalstoreImpl {
/// Gets a [CollectionRef] for the specified Localstore path.
CollectionRef collection(String path);
}
@@ -0,0 +1,14 @@
part of 'localstore.dart';
/// An options class that configures the behavior of set() calls in
/// [DocumentRef].
final class SetOptions {
final bool _merge;
/// Changes the behavior of a set() call to only replace the values specified
/// in its data argument.
bool get merge => _merge;
/// Creates a [SetOptions] instance.
SetOptions({this._merge = false});
}
@@ -0,0 +1,41 @@
import 'dart:async';
import 'utils_impl.dart';
/// Utils class
class Utils implements UtilsImpl {
Utils._();
static final Utils _utils = Utils._();
static Utils get instance => _utils;
@override
void clearCache() {
throw UnimplementedError('Web is not supported');
}
@override
Future<Map<String, dynamic>?> get(
String path, [
bool? isCollection = false,
List<List>? conditions,
]) async {
throw UnimplementedError('Web is not supported');
}
@override
Future<dynamic>? set(Map<String, dynamic> data, String path) {
throw UnimplementedError('Web is not supported');
}
@override
Future delete(String path) async {
throw UnimplementedError('Web is not supported');
}
@override
Stream<Map<String, dynamic>> stream(String path, [List<List>? conditions]) {
throw UnimplementedError('Web is not supported');
}
}
@@ -0,0 +1,312 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:path/path.dart' as p;
import '../localstore.dart';
import 'utils_impl.dart';
import 'package:logging/logging.dart';
final _log = Logger('Localstore');
final class Utils implements UtilsImpl {
Utils._();
static final Utils _utils = Utils._();
static final lastPathComponentRegEx = RegExp(r'[^/\\]+[/\\]?$');
static Utils get instance => _utils;
final _storageCache = <String, StreamController<Map<String, dynamic>>>{};
final _fileCache = <String, File>{};
final _locks = <String, Future<void>>{};
/// Clears the cache
@override
void clearCache() {
_storageCache.clear();
_fileCache.clear();
}
Future<T> _synchronized<T>(String path, Future<T> Function() action) async {
final previous = _locks[path] ?? Future.value();
final controller = Completer<T>();
final newFuture = previous
.then((_) async {
try {
final result = await action();
controller.complete(result);
} catch (e, st) {
controller.completeError(e, st);
}
})
.catchError((_) {});
_locks[path] = newFuture;
// Cleanup
newFuture.whenComplete(() {
if (_locks[path] == newFuture) {
_locks.remove(path);
}
});
return controller.future;
}
String _resolvePath(String dbPath, String path) {
var relativePath = path;
if (relativePath.startsWith('/') || relativePath.startsWith('\\')) {
relativePath = relativePath.substring(1);
}
return p.join(dbPath, relativePath);
}
@override
Future<Map<String, dynamic>?> get(
String path, [
bool? isCollection = false,
List<List>? conditions,
]) async {
// Fetch the documents for this collection
if (isCollection != null && isCollection == true) {
final dbDir = await Localstore.instance.databaseDirectory;
final fullPath = _resolvePath(dbDir.path, path);
final dir = Directory(fullPath);
if (!await dir.exists()) {
return {};
}
final entries = await dir.list(recursive: false).toList();
return await _getAll(entries.whereType<File>().toList());
} else {
return _synchronized(path, () async {
try {
// Reads the document referenced by this [DocumentRef].
final file = await _getFile(path);
final randomAccessFile = await file!.open(mode: FileMode.append);
try {
final data = await _readFile(randomAccessFile);
if (data is Map<String, dynamic>) {
final key = path.replaceAll(lastPathComponentRegEx, '');
// ignore: close_sinks
final storage = _storageCache.putIfAbsent(
key,
() => _newStream(key),
);
storage.add(data);
return data;
}
} finally {
await randomAccessFile.close();
}
} on PathNotFoundException {
// return null if not found
}
return null;
});
}
}
@override
Future<dynamic>? set(Map<String, dynamic> data, String path) =>
_writeFile(data, path);
@override
Future delete(String path) async {
if (path.endsWith(Platform.pathSeparator)) {
await _deleteDirectory(path);
} else {
await _synchronized(path, () => _deleteFile(path));
}
}
@override
Stream<Map<String, dynamic>> stream(String path, [List<List>? conditions]) {
// ignore: close_sinks
var storage = _storageCache[path];
if (storage == null) {
storage = _storageCache.putIfAbsent(path, () => _newStream(path));
} else {
_initStream(storage, path);
}
return storage.stream;
}
Future<Map<String, dynamic>?> _getAll(List<FileSystemEntity> entries) async {
_log.finest('Getting all entries from ${entries.length} files');
final items = <String, dynamic>{};
final dbDir = await Localstore.instance.databaseDirectory;
for (final e in entries) {
final relativePath = p.relative(e.path, from: dbDir.path);
final path = Platform.isWindows
? relativePath.replaceAll(p.separator, '/')
: relativePath;
await _synchronized(path, () async {
final file = await _getFile(path);
try {
final randomAccessFile = await file!.open(mode: FileMode.append);
try {
final data = await _readFile(randomAccessFile);
if (data is Map<String, dynamic>) {
items[path] = data;
}
} finally {
await randomAccessFile.close();
}
} on PathNotFoundException {
// ignore if not found
} catch (e) {
_log.warning('Error reading file $path: $e');
}
});
}
if (items.isEmpty) return null;
return items;
}
/// Streams all file in the path
StreamController<Map<String, dynamic>> _newStream(String path) {
final storage = StreamController<Map<String, dynamic>>.broadcast();
_initStream(storage, path);
return storage;
}
Future _initStream(
StreamController<Map<String, dynamic>> storage,
String path,
) async {
final dbDir = await Localstore.instance.databaseDirectory;
final fullPath = _resolvePath(dbDir.path, path);
final dir = Directory(fullPath);
try {
if (!await dir.exists()) return;
final entries = await dir.list(recursive: false).toList();
for (final e in entries) {
if (e is! File) continue;
final relativePath = p.relative(e.path, from: dbDir.path);
final filePath = Platform.isWindows
? relativePath.replaceAll(p.separator, '/')
: relativePath;
// We use synchronized reading
await _synchronized(filePath, () async {
final file = await _getFile(filePath);
final randomAccessFile = await file!.open(mode: FileMode.append);
try {
final data = await _readFile(randomAccessFile);
if (data is Map<String, dynamic>) {
storage.add(data);
}
} finally {
await randomAccessFile.close();
}
});
}
} catch (e) {
// ignore
}
}
Future<dynamic> _readFile(RandomAccessFile file) async {
final length = await file.length();
await file.setPosition(0);
final buffer = Uint8List(length);
await file.readInto(buffer);
try {
final contentText = utf8.decode(buffer);
final data = json.decode(contentText) as Map<String, dynamic>;
return data;
} catch (e) {
return e;
}
}
Future<File?> _getFile(String path) async {
final dbDir = await Localstore.instance.databaseDirectory;
final file = _fileCache[path] ?? File(_resolvePath(dbDir.path, path));
if (!await file.exists()) {
try {
await file.create(recursive: true);
} on FileSystemException {
// Handle race condition where directory was being deleted/modified concurrently
await Future.delayed(const Duration(milliseconds: 50));
if (!await file.exists()) {
try {
await file.create(recursive: true);
} on FileSystemException catch (e) {
_log.warning('Could not create file $path: $e');
_fileCache.remove(path);
return null;
}
}
}
}
_fileCache[path] = file;
return file;
}
Future _writeFile(Map<String, dynamic> data, String path) =>
_synchronized(path, () async {
final serialized = json.encode(data);
final buffer = utf8.encode(serialized);
try {
final file = await _getFile(path);
if (file == null) return;
final randomAccessFile = await file.open(mode: FileMode.append);
try {
await randomAccessFile.lock();
await randomAccessFile.setPosition(0);
await randomAccessFile.writeFrom(buffer);
await randomAccessFile.truncate(buffer.length);
await randomAccessFile.unlock();
} finally {
await randomAccessFile.close();
}
} on FileSystemException catch (e) {
_log.warning('FileSystemException writing file $path: $e');
return;
} catch (e) {
_log.warning('Error writing file $path: $e');
return;
}
final key = path.replaceAll(lastPathComponentRegEx, '');
// ignore: close_sinks
final storage = _storageCache.putIfAbsent(key, () => _newStream(key));
storage.add(data);
});
Future _deleteFile(String path) async {
final dbDir = await Localstore.instance.databaseDirectory;
final file = File(_resolvePath(dbDir.path, path));
if (await file.exists()) {
try {
await file.delete();
} catch (e) {
_log.finest(e);
}
}
_fileCache.remove(path);
}
Future _deleteDirectory(String path) async {
final dbDir = await Localstore.instance.databaseDirectory;
final dir = Directory(_resolvePath(dbDir.path, path));
_fileCache.removeWhere((key, value) => key.startsWith(path));
if (await dir.exists()) {
try {
await dir.delete(recursive: true);
} catch (e) {
_log.finest(e);
}
}
_fileCache.removeWhere((key, value) => key.startsWith(path));
}
}
@@ -0,0 +1,11 @@
abstract class UtilsImpl {
void clearCache();
Future<Map<String, dynamic>?> get(
String path, [
bool? isCollection = false,
List<List>? conditions,
]);
Future<dynamic>? set(Map<String, dynamic> data, String path);
Future delete(String path);
Stream<Map<String, dynamic>> stream(String path, [List<List>? conditions]);
}
+665
View File
@@ -0,0 +1,665 @@
import 'dart:convert';
import 'exceptions.dart';
import 'task.dart';
export 'options/mtls.dart';
/// Defines a set of possible states which a [Task] can be in.
enum TaskStatus {
/// Task is enqueued on the native platform and waiting to start
///
/// It may wait for resources, or for an appropriate network to become
/// available before starting the actual download and changing state to
/// `running`.
enqueued,
/// Task is running, i.e. actively downloading
running,
/// Task has completed successfully
///
/// This is a final state
complete,
/// Task has completed because the url was not found (Http status code 404)
///
/// This is a final state
notFound,
/// Task has failed due to an exception
///
/// This is a final state
failed,
/// Task has been canceled by the user or the system
///
/// This is a final state
canceled,
/// Task failed, and is now waiting to retry
///
/// The task is held in this state until the exponential backoff time for
/// this retry has passed, and will then be rescheduled on the native
/// platform, switching state to `enqueued` and then `running`
waitingToRetry,
/// Task is in paused state and may be able to resume
///
/// To resume a paused Task, call [FileDownloader.resume]. If the resume is
/// possible, status will change to [TaskStatus.running] and continue from
/// there. If resume fails (e.g. because the temp file with the partial
/// download has been deleted by the operating system) status will switch
/// to [TaskStatus.failed]
paused;
/// True if this state is one of the 'final' states, meaning no more
/// state changes are possible
bool get isFinalState => switch (this) {
.complete || .notFound || .failed || .canceled => true,
.enqueued || .running || .waitingToRetry || .paused => false,
};
/// True if this state is not a 'final' state, meaning more
/// state changes are possible
bool get isNotFinalState => !isFinalState;
}
/// Base directory in which files will be stored, based on their relative
/// path.
///
/// These correspond to the directories provided by the path_provider package
enum BaseDirectory {
/// As returned by getApplicationDocumentsDirectory()
applicationDocuments,
/// As returned by getTemporaryDirectory()
temporary,
/// As returned by getApplicationSupportDirectory()
applicationSupport,
/// As returned by getApplicationLibrary() on iOS. For other platforms
/// this resolves to the subdirectory 'Library' created in the directory
/// returned by getApplicationSupportDirectory()
applicationLibrary,
/// System root directory. This allows you to set a path to any directory
/// via [Task.directory]. Only use this if you are certain that this
/// path is stable. on iOS and Android, references to paths within
/// the application's directory structure are *not* stable, and you
/// should use [applicationDocuments], [applicationSupport] or
/// [applicationLibrary] instead to avoid errors.
root,
}
/// Type of updates requested for a task or group of tasks
enum Updates {
/// no status change or progress updates
none,
/// only status changes
status,
/// only progress updates while downloading, no status change updates
progress,
/// Status change updates and progress updates while downloading
statusAndProgress,
}
/// Signature for a function you can register to be called
/// when the status of a [Task] changes.
typedef TaskStatusCallback = void Function(TaskStatusUpdate update);
/// Signature for a function you can register to be called
/// for every progress change of a [Task].
///
/// A successfully completed task will always finish with progress 1.0
/// [TaskStatus.failed] results in progress -1.0
/// [TaskStatus.canceled] results in progress -2.0
/// [TaskStatus.notFound] results in progress -3.0
/// [TaskStatus.waitingToRetry] results in progress -4.0
/// These constants are available as [progressFailed] etc
typedef TaskProgressCallback = void Function(TaskProgressUpdate update);
/// Signature for function you can register to be called when a notification
/// is tapped by the user
typedef TaskNotificationTapCallback = void Function(
Task task,
NotificationType notificationType,
);
/// Signature for a function you can provide to the [FileDownloader.downloadBatch] or
/// [FileDownloader.uploadBatch] that will be called upon completion of each task
/// in the batch.
///
/// [succeeded] will count the number of successful downloads, and
/// [failed] counts the number of failed downloads (for any reason).
typedef BatchProgressCallback = void Function(int succeeded, int failed);
/// Contains tasks and results related to a batch of tasks
class Batch(
final List<Task> tasks,
final BatchProgressCallback? batchProgressCallback,
) {
final results = <Task, TaskStatus>{};
/// Returns an Iterable with successful tasks in this batch
Iterable<Task> get succeeded => results.entries
.where((entry) => entry.value == TaskStatus.complete)
.map((e) => e.key);
/// Returns the number of successful tasks in this batch
int get numSucceeded =>
results.values.where((result) => result == TaskStatus.complete).length;
/// Returns an Iterable with failed tasks in this batch
Iterable<Task> get failed => results.entries
.where((entry) => entry.value != TaskStatus.complete)
.map((e) => e.key);
/// Returns the number of failed downloads in this batch
int get numFailed => results.values.length - numSucceeded;
}
/// Base class for updates related to [task]. Actual updates are
/// either a status update or a progress update.
///
/// When receiving an update, test if the update is a
/// [TaskStatusUpdate] or a [TaskProgressUpdate]
/// and treat the update accordingly
sealed class TaskUpdate {
final Task task;
const TaskUpdate(this.task);
/// Create object from [json]
TaskUpdate.fromJson(Map<String, dynamic> json)
: task = Task.createFromJson(json['task'] ?? json);
/// Return JSON Map representing object
Map<String, dynamic> toJson() => {'task': task.toJson()};
}
/// A status update
///
/// Contains [TaskStatus] and, if [TaskStatus.failed] possibly a
/// [TaskException] and if this is a final state possibly [responseBody],
/// [responseHeaders], [responseStatusCode], [mimeType] and [charSet].
/// Note: header names in [responseHeaders] are converted to lowercase
class TaskStatusUpdate extends TaskUpdate {
final TaskStatus status; // note: serialized as 'taskStatus'
final TaskException? exception;
final String? responseBody;
final int? responseStatusCode;
final Map<String, String>? responseHeaders;
final String? mimeType; // derived from Content-Type header
final String? charSet; // derived from Content-Type header
const TaskStatusUpdate(
super.task,
this.status, [
this.exception,
this.responseBody,
this.responseHeaders,
this.responseStatusCode,
this.mimeType,
this.charSet,
]);
/// Create object from [json]
TaskStatusUpdate.fromJson(super.json)
: status = TaskStatus.values[(json['taskStatus'] as num?)?.toInt() ?? 0],
exception = json['exception'] != null
? TaskException.fromJson(json['exception'])
: null,
responseBody = json['responseBody'],
responseHeaders = json['responseHeaders'] != null
? Map.from(json['responseHeaders'])
: null,
responseStatusCode = (json['responseStatusCode'] as num?)?.toInt(),
mimeType = json['mimeType'],
charSet = json['charSet'],
super.fromJson();
/// Create object from [jsonString]
factory TaskStatusUpdate.fromJsonString(String jsonString) =>
TaskStatusUpdate.fromJson(jsonDecode(jsonString));
/// Return JSON Map representing object
@override
Map<String, dynamic> toJson() => {
...super.toJson(),
'taskStatus': status.index,
'exception': exception?.toJson(),
'responseBody': responseBody,
'responseHeaders': responseHeaders,
'responseStatusCode': responseStatusCode,
'mimeType': mimeType,
'charSet': charSet,
};
TaskStatusUpdate copyWith({
Task? task,
TaskStatus? status,
TaskException? exception,
String? responseBody,
Map<String, String>? responseHeaders,
int? responseStatusCode,
String? mimeType,
String? charSet,
}) => TaskStatusUpdate(
task ?? this.task,
status ?? this.status,
exception ?? this.exception,
responseBody ?? this.responseBody,
responseHeaders ?? this.responseHeaders,
responseStatusCode ?? this.responseStatusCode,
mimeType ?? this.mimeType,
charSet ?? this.charSet,
);
}
/// A progress update
///
/// A successfully downloaded task will always finish with progress 1.0
///
/// [TaskStatus.failed] results in progress -1.0
/// [TaskStatus.canceled] results in progress -2.0
/// [TaskStatus.notFound] results in progress -3.0
/// [TaskStatus.waitingToRetry] results in progress -4.0
///
/// [expectedFileSize] will only be representative if the 0 < [progress] < 1,
/// so NOT representative when progress == 0 or progress == 1, and
/// will be -1 if the file size is not provided by the server or otherwise
/// not known.
/// [networkSpeed] is valid if positive, expressed in MB/second
/// [timeRemaining] is valid if positive
///
/// Use the [hasExpectedFileSize], [hasNetworkSpeed] and [hasTimeRemaining]
/// getters to determine whether a field is valid
class TaskProgressUpdate extends TaskUpdate {
final double progress;
final int expectedFileSize;
final double networkSpeed; // in MB/s
final Duration timeRemaining;
const TaskProgressUpdate(
super.task,
this.progress, [
this.expectedFileSize = -1,
this.networkSpeed = -1,
this.timeRemaining = const Duration(seconds: -1),
]);
/// Create object from [json]
TaskProgressUpdate.fromJson(super.json)
: progress = (json['progress'] as num?)?.toDouble() ?? progressFailed,
expectedFileSize = (json['expectedFileSize'] as num?)?.toInt() ?? -1,
networkSpeed = (json['networkSpeed'] as num?)?.toDouble() ?? -1,
timeRemaining = Duration(
seconds: (json['timeRemaining'] as num?)?.toInt() ?? -1,
),
super.fromJson();
/// Create object from [jsonString]
factory TaskProgressUpdate.fromJsonString(String jsonString) =>
TaskProgressUpdate.fromJson(jsonDecode(jsonString));
/// Return JSON Map representing object
@override
Map<String, dynamic> toJson() => {
...super.toJson(),
'progress': progress,
'expectedFileSize': expectedFileSize,
'networkSpeed': networkSpeed,
'timeRemaining': timeRemaining.inSeconds,
};
/// If true, [expectedFileSize] contains a valid value
bool get hasExpectedFileSize => expectedFileSize >= 0;
/// If true, [networkSpeed] contains a valid value
bool get hasNetworkSpeed => networkSpeed >= 0;
/// If true, [timeRemaining] contains a valid value
bool get hasTimeRemaining => !timeRemaining.isNegative;
/// Use the [hasExpectedFileSize], [hasNetworkSpeed] and [hasTimeRemaining]
/// getters to determine whether a field is valid
///
/// String is '-- MB/s' if N/A, otherwise in MB/s or kB/s
String get networkSpeedAsString => switch (networkSpeed) {
<= 0 => '-- MB/s',
>= 1 => '${networkSpeed.round()} MB/s',
_ => '${(networkSpeed * 1000).round()} kB/s',
};
/// String is '--:--' if N/A, otherwise HH:MM:SS or MM:SS
String get timeRemainingAsString => switch (timeRemaining.inSeconds) {
<= 0 => '--:--',
< 3600 =>
'${timeRemaining.inMinutes.toString().padLeft(2, "0")}'
':${timeRemaining.inSeconds.remainder(60).toString().padLeft(2, "0")}',
_ =>
'${timeRemaining.inHours}'
':${timeRemaining.inMinutes.remainder(60).toString().padLeft(2, "0")}'
':${timeRemaining.inSeconds.remainder(60).toString().padLeft(2, "0")}',
};
@override
String toString() =>
'TaskProgressUpdate{progress: $progress, expectedFileSize: $expectedFileSize, networkSpeed: $networkSpeed, timeRemaining: $timeRemaining}';
}
// Progress values representing a status
const progressRunning = 0.0;
const progressComplete = 1.0;
const progressFailed = -1.0;
const progressCanceled = -2.0;
const progressNotFound = -3.0;
const progressWaitingToRetry = -4.0;
const progressPaused = -5.0;
/// Holds data associated with a resume
class ResumeData {
final Task task;
final String data;
final int requiredStartByte;
final String? eTag;
const ResumeData(
this.task,
this.data, [
this.requiredStartByte = 0,
this.eTag,
]);
/// Create object from [json]
ResumeData.fromJson(Map<String, dynamic> json)
: task = Task.createFromJson(json['task']),
data = json['data'] as String,
requiredStartByte = (json['requiredStartByte'] as num?)?.toInt() ?? 0,
eTag = json['eTag'] as String?;
/// Create object from [jsonString]
factory ResumeData.fromJsonString(String jsonString) =>
ResumeData.fromJson(jsonDecode(jsonString));
/// Return JSON Map representing object
Map<String, dynamic> toJson() => {
'task': task.toJson(),
'data': data,
'requiredStartByte': requiredStartByte,
'eTag': eTag,
};
String get taskId => task.taskId;
/// The tempFilepath contained in the [data] field
String get tempFilepath => data;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ResumeData &&
runtimeType == other.runtimeType &&
task == other.task &&
data == other.data &&
requiredStartByte == other.requiredStartByte &&
eTag == other.eTag;
@override
int get hashCode =>
task.hashCode ^
data.hashCode ^
requiredStartByte.hashCode ^
(eTag?.hashCode ?? 0);
}
/// Types of undelivered data that can be requested
enum Undelivered {
/// Resume data
resumeData,
/// Status updates
statusUpdates,
/// Progress updates
progressUpdates,
}
/// Notification types, as configured in [TaskNotificationConfig] and passed
/// on to [TaskNotificationTapCallback]
enum NotificationType {
/// Task is running
running,
/// Task has completed
complete,
/// Task has failed
error,
/// Task is paused
paused,
/// Task is canceled
canceled,
}
/// Notification specification for a [Task]
///
/// [body] and [title] may contain special strings to substitute display values:
/// {filename] to insert the filename
/// {progress} to insert progress in %
/// {networkSpeed} to insert the network speed in MB/s or kB/s, or '--' if N/A
/// {timeRemaining} to insert the estimated time remaining to complete the task
/// in HH:MM:SS or MM:SS or --:-- if N/A
///
/// Actual appearance of notification is dependent on the platform, e.g.
/// on iOS {progress} is not available and ignored
final class const TaskNotification(final String title, final String body) {
/// Return JSON Map representing object
Map<String, dynamic> toJson() => {"title": title, "body": body};
}
/// Notification configuration object
///
/// Determines how a [taskOrGroup] or [Task.group] of tasks needs to be notified
///
/// [running] is the notification used while the task is in progress
/// [complete] is the notification used when the task completed
/// [error] is the notification used when something went wrong,
/// including pause, failed and notFound status
/// [progressBar] if set will show a progress bar
/// [tapOpensFile] if set will attempt to open the file when the [complete]
/// notification is tapped
/// [groupNotificationId] if set will group all notifications with the same
/// [groupNotificationId] and change the progress bar to number of finished
/// tasks versus total number of tasks in the groupNotification.
/// Use {finished} and {total} tokens in the [TaskNotification.title] and
/// [TaskNotification.body] to substitute. Task-specific substitutions
/// such as {filename} are not valid.
/// The groupNotification is considered [complete] when there are no
/// more tasks running within that group, and at that point the
/// [complete] notification is shown (if configured). If any task in the
/// groupNotification fails, the [error] notification is shown.
/// The first character of the [groupNotificationId] cannot be '*'.
final class TaskNotificationConfig {
final dynamic taskOrGroup;
final TaskNotification? running;
final TaskNotification? complete;
final TaskNotification? error;
final TaskNotification? paused;
final TaskNotification? canceled;
final bool progressBar;
final bool tapOpensFile;
final String groupNotificationId;
/// Create notification configuration that determines what notifications are shown,
/// whether a progress bar is shown (Android only), and whether tapping
/// the 'complete' notification opens the downloaded file.
///
/// [running] is the notification used while the task is in progress
/// [complete] is the notification used when the task completed
/// [error] is the notification used when something went wrong,
/// including failed and notFound status
/// [paused] is the notification shown when the task is paused
/// [canceled] is the notification shown when the task is canceled programmatically
/// or by the user via a notification button
/// [progressBar] if set will show a progress bar
/// [tapOpensFile] if set will attempt to open the file when the [complete]
/// notification is tapped
/// [groupNotificationId] if set will group all notifications with the same
/// [groupNotificationId] and change the progress bar to number of finished
/// tasks versus total number of tasks in the groupNotification.
/// Use {numFinished}, {numFailed} and {numTotal} tokens in the [TaskNotification.title]
/// and [TaskNotification.body] to substitute. Task-specific substitutions
/// such as {filename} are not valid.
/// The groupNotification is considered [complete] when there are no
/// more tasks running within that group, and at that point the
/// [complete] notification is shown (if configured). If any task in the
/// groupNotification fails, the [error] notification is shown.
/// The first character of the [groupNotificationId] cannot be '*'.
TaskNotificationConfig({
this.taskOrGroup,
this.running,
this.complete,
this.error,
this.paused,
this.canceled,
this.progressBar = false,
this.tapOpensFile = false,
this.groupNotificationId = '',
}) {
assert(
running != null ||
complete != null ||
error != null ||
paused != null ||
canceled != null,
'At least one notification must be set',
);
}
/// Return JSON Map representing object, excluding the [taskOrGroup] field,
/// as the JSON map is only required to pass along the config with a task
Map<String, dynamic> toJson() => {
'running': running?.toJson(),
'complete': complete?.toJson(),
'error': error?.toJson(),
'paused': paused?.toJson(),
'canceled': canceled?.toJson(),
'progressBar': progressBar,
'tapOpensFile': tapOpensFile,
'groupNotificationId': groupNotificationId,
};
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TaskNotificationConfig &&
runtimeType == other.runtimeType &&
taskOrGroup == other.taskOrGroup;
@override
int get hashCode => taskOrGroup.hashCode;
}
/// Shared storage destinations
enum SharedStorage {
/// The 'Downloads' directory
downloads,
/// The 'Photos' or 'Images' or 'Pictures' directory
images,
/// The 'Videos' or 'Movies' directory
video,
/// The 'Music' or 'Audio' directory
audio,
/// Android-only: the 'Files' directory
files,
/// Android-only: the 'external storage' directory
external,
}
final class Config {
// Config topics
/// Config string for request timeout
static const requestTimeout = 'requestTimeout';
/// Config string for resource timeout
static const resourceTimeout = 'resourceTimeout';
/// Config string for check available space
static const checkAvailableSpace = 'checkAvailableSpace';
/// Config string for proxy
static const proxy = 'proxy';
/// Config string for bypass TLS certificate validation
static const bypassTLSCertificateValidation =
'bypassTLSCertificateValidation';
/// Config string for mutual TLS (mTLS)
static const mTLS = 'mTLS';
/// Config string for run in foreground
static const runInForeground = 'runInForeground';
/// Config string for run in foreground if file larger than
static const runInForegroundIfFileLargerThan =
'runInForegroundIfFileLargerThan';
/// Config string for localize
static const localize = 'localize';
/// Config string for exclude from cloud backup
static const excludeFromCloudBackup = 'excludeFromCloudBackup';
/// Config string for use cache dir
static const useCacheDir = 'useCacheDir';
/// Config string for use external storage
static const useExternalStorage = 'useExternalStorage';
/// Config string for holding queue
static const holdingQueue = 'holdingQueue';
/// Config string for skip existing files
static const skipExistingFiles = 'skipExistingFiles';
/// Config string for temp file path
static const tempFilePath = 'tempFilePath';
// Config arguments
/// Config argument for always
static const always = 'always'; // int 0 on native side
/// Config argument for never
static const never = 'never'; // int -1 on native side
/// Config argument for when able
static const whenAble = 'whenAble'; // int -2 on native side
/// Returns the int equivalent of commonly used String arguments
///
/// The int equivalent is used in communication with the native downloader
static int argToInt(String argument) => switch (argument) {
Config.always => 0,
Config.whenAble => -2,
Config.never => -1,
_ => throw ArgumentError('Argument $argument cannot be converted to int'),
};
}
/// Wifi requirement modes at the application level
enum RequireWiFi { asSetByTask, forAllTasks, forNoTasks }
@@ -0,0 +1,886 @@
import 'dart:async';
import 'dart:convert';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'base_downloader.dart';
import 'chunk.dart';
import 'exceptions.dart';
import 'file_downloader.dart';
import 'models.dart';
import 'json_processor.dart';
import 'permissions.dart';
import 'queue/serial_job_queue.dart';
import 'task.dart';
/// Implementation of download functionality for native platforms
///
/// Uses [MethodChannel] to communicate with native platforms
abstract base class NativeDownloader extends BaseDownloader {
static const methodChannel = MethodChannel(
'com.bbflight.background_downloader',
);
static const _backgroundChannel = MethodChannel(
'com.bbflight.background_downloader.background',
);
late final SerialJobQueue<MethodCall, dynamic> _jobQueue;
/// Initializes the background channel and starts listening for messages from
/// the native side
@override
Future<void> initialize() async {
await super.initialize();
WidgetsFlutterBinding.ensureInitialized();
// listen to the background channel, receiving updates on download status
// or progress.
// The job queue ensures that messages are processed in order, even though
// the processing itself is asynchronous (using [compute])
_jobQueue = SerialJobQueue(_handleBackgroundMessage);
_backgroundChannel.setMethodCallHandler(_jobQueue.add);
}
/// Handles the background message
///
/// First argument is the Task as JSON string, next argument(s) depends
/// on the method.
///
/// If the task JsonString is empty, a dummy task will be created
Future<dynamic> _handleBackgroundMessage(MethodCall call) async {
final args = call.arguments as List<dynamic>;
final taskJsonString = args.first as String;
final task = taskJsonString.isNotEmpty
? await JsonProcessor().decodeTask(taskJsonString)
: DownloadTask(url: 'url');
final message = (
call.method,
args.length > 2
? args.getRange(1, args.length).toList(growable: false)
: args[1],
);
switch (message) {
// simple status update
case ('statusUpdate', final int statusOrdinal):
final status = TaskStatus.values[statusOrdinal];
if (task.group != BaseDownloader.chunkGroup) {
processStatusUpdate(TaskStatusUpdate(task, status));
} else {
// this is a chunk task, so pass to native
Future.delayed(const Duration(milliseconds: 100)).then(
(_) => methodChannel.invokeMethod('chunkStatusUpdate', [
Chunk.getParentTaskId(task),
task.taskId,
status.index,
null,
null,
]),
);
}
// status update with responseBody, responseHeaders, responseStatusCode, mimeType and charSet (normal completion)
case (
'statusUpdate',
[
final int statusOrdinal,
final String? responseBody,
final Map<Object?, Object?>? responseHeaders,
final int? responseStatusCode,
final String? mimeType,
final String? charSet,
],
):
final status = TaskStatus.values[statusOrdinal];
if (task.group != BaseDownloader.chunkGroup) {
final Map<String, String>? cleanResponseHeaders =
responseHeaders == null
? null
: {
for (final entry in responseHeaders.entries.where(
(entry) => entry.key != null && entry.value != null,
))
entry.key.toString().toLowerCase(): entry.value.toString(),
};
processStatusUpdate(
TaskStatusUpdate(
task,
status,
null,
responseBody,
cleanResponseHeaders,
responseStatusCode,
mimeType,
charSet,
),
);
} else {
// this is a chunk task, so pass to native
Future.delayed(const Duration(milliseconds: 100)).then(
(_) => methodChannel.invokeMethod('chunkStatusUpdate', [
Chunk.getParentTaskId(task),
task.taskId,
status.index,
null,
responseBody,
]),
);
}
// status update with TaskException and responseBody
case (
'statusUpdate',
[
final int statusOrdinal,
final String typeString,
final String description,
final int httpResponseCode,
final String? responseBody,
],
):
final status = TaskStatus.values[statusOrdinal];
TaskException? exception;
if (status == TaskStatus.failed) {
exception = TaskException.fromTypeString(
typeString,
description,
httpResponseCode,
);
}
if (task.group != BaseDownloader.chunkGroup) {
processStatusUpdate(
TaskStatusUpdate(task, status, exception, responseBody),
);
} else {
// this is a chunk task, so pass to native
Future.delayed(const Duration(milliseconds: 100)).then(
(_) => methodChannel.invokeMethod('chunkStatusUpdate', [
Chunk.getParentTaskId(task),
task.taskId,
status.index,
exception?.toJsonString(),
responseBody,
]),
);
}
case (
'progressUpdate',
[
final double progress,
final int expectedFileSize,
final double networkSpeed,
final int timeRemaining,
],
):
if (task.group != BaseDownloader.chunkGroup) {
processProgressUpdate(
TaskProgressUpdate(
task,
progress,
expectedFileSize,
networkSpeed,
Duration(milliseconds: timeRemaining),
),
);
} else {
// this is a chunk task, so pass parent taskId,
// chunk taskId and progress to native
Future.delayed(const Duration(milliseconds: 100)).then(
(_) => methodChannel.invokeMethod('chunkProgressUpdate', [
Chunk.getParentTaskId(task),
task.taskId,
progress,
]),
);
}
case ('canResume', final bool canResume):
setCanResume(task, canResume);
// resumeData Android and Desktop variant
case (
'resumeData',
[final String data, final int requiredStartByte, final String? eTag],
):
setResumeData(ResumeData(task, data, requiredStartByte, eTag));
// resumeData iOS and ParallelDownloads variant
case ('resumeData', final String data):
setResumeData(ResumeData(task, data));
case ('notificationTap', final int notificationTypeOrdinal):
final notificationType =
NotificationType.values[notificationTypeOrdinal];
processNotificationTap(task, notificationType);
return true; // this message requires a confirmation
// from ParallelDownloadTask
case ('enqueueChild', final String childTaskJsonString):
final childTask = await JsonProcessor().decodeTask(childTaskJsonString);
// The parent treats this acknowledgement as proof that a child exists.
// Returning before native enqueue finishes strands the parent forever
// when WorkManager rejects that child. Return the real result instead.
try {
return await FileDownloader().enqueue(childTask);
} catch (error) {
log.warning('Native child enqueue failed', error);
return false;
}
// from ParallelDownloadTask
case ('cancelTasksWithId', final String listOfTaskIdsJson):
final taskIds = List<String>.from(jsonDecode(listOfTaskIdsJson));
Future.delayed(const Duration(milliseconds: 100))
.then((_) => FileDownloader().cancelTasksWithIds(taskIds));
// from ParallelDownloadTask
case ('pauseTasks', final String listOfTasksJson):
final listOfTasks = await JsonProcessor().decodeDownloadTaskList(
listOfTasksJson,
);
Future.delayed(const Duration(milliseconds: 100)).then((_) async {
for (final chunkTask in listOfTasks) {
await FileDownloader().pause(chunkTask);
}
});
// for permission request results
case ('permissionRequestResult', final int statusOrdinal):
permissionsService.onPermissionRequestResult(
PermissionStatus.values[statusOrdinal],
);
default:
log.warning('Background channel: no match for message $message');
throw ArgumentError(
'Background channel: no match for message $message',
);
}
await flushDatabaseUpdates();
return true;
}
@override
Future<bool> enqueue(Task task) async {
super.enqueue(task);
final notificationConfig = notificationConfigForTask(task);
return await methodChannel.invokeMethod<bool>('enqueue', [
jsonEncode(task.toJson()),
if (notificationConfig != null)
jsonEncode(notificationConfig.toJson())
else
null,
]) ??
false;
}
@override
Future<List<bool>> enqueueAll(Iterable<Task> tasks) async {
for (final task in tasks.where((task) => task.allowPause)) {
canResumeTask[task] = Completer();
}
tasks.forEach(registerTaskNotificationConfig);
final (
String tasksJsonString,
String notificationConfigsJsonString,
) = await JsonProcessor().encodeTaskAndNotificationConfig(
tasks,
notificationConfigs,
);
final result =
await methodChannel.invokeMethod<List<Object?>>('enqueueAll', [
tasksJsonString,
notificationConfigsJsonString,
]) ??
[];
return result.map((item) => item is bool ? item : false).toList();
}
@override
Future<int> reset(String group) async {
final retryAndPausedTaskCount = await super.reset(group);
final nativeCount =
await methodChannel.invokeMethod<int>('reset', group) ?? 0;
return retryAndPausedTaskCount + nativeCount;
}
@override
Future<List<Task>> allTasks(
String group,
bool includeTasksWaitingToRetry,
allGroups,
) async {
final retryAndPausedTasks = await super.allTasks(
group,
includeTasksWaitingToRetry,
allGroups,
);
final result =
await methodChannel.invokeMethod<List<dynamic>?>(
'allTasks',
allGroups ? null : group,
) ??
[];
final tasks = await JsonProcessor().decodeTaskList(result);
return [...retryAndPausedTasks, ...tasks];
}
@override
Future<bool> cancelPlatformTasksWithIds(List<String> taskIds) async =>
await methodChannel.invokeMethod<bool>('cancelTasksWithIds', taskIds) ??
false;
@override
Future<Task?> taskForId(String taskId) async {
final task = await super.taskForId(taskId);
if (task != null) {
return task;
}
final jsonString = await methodChannel.invokeMethod<String>(
'taskForId',
taskId,
);
if (jsonString != null) {
return Task.createFromJsonString(jsonString);
}
return null;
}
@override
Future<bool> pause(Task task) async =>
await methodChannel.invokeMethod<bool>('pause', task.taskId) ?? false;
@override
Future<List<bool>> pauseTaskList(Iterable<Task> tasksToPause) async {
final taskIds = tasksToPause
.map((task) => task.taskId)
.toList(growable: false);
final results = await methodChannel.invokeMethod<List<Object?>>(
'pauseAll',
taskIds,
);
return results?.cast<bool>() ?? tasksToPause.map((task) => false).toList();
}
@override
Future<bool> resume(Task task) async {
if (await super.resume(task)) {
task = awaitTasks.containsKey(task)
? awaitTasks.keys.firstWhere(
(awaitTask) => awaitTask.taskId == task.taskId,
)
: task;
final taskResumeData = await getResumeData(task.taskId);
if (taskResumeData != null) {
final notificationConfig = notificationConfigForTask(task);
final enqueueSuccess =
await methodChannel.invokeMethod<bool>('enqueue', [
jsonEncode(task.toJson()),
if (notificationConfig != null)
jsonEncode(notificationConfig.toJson())
else
null,
taskResumeData.data,
taskResumeData.requiredStartByte,
taskResumeData.eTag,
]) ??
false;
if (enqueueSuccess && task is ParallelDownloadTask) {
return resumeChunkTasks(task, taskResumeData);
}
return enqueueSuccess;
}
}
return false;
}
@override
Future<bool> requireWiFi(
RequireWiFi requirement,
rescheduleRunningTasks,
alsoRestartUploads,
) async =>
await methodChannel.invokeMethod('requireWiFi', [
requirement.index,
rescheduleRunningTasks,
alsoRestartUploads,
]) ??
false;
@override
Future<RequireWiFi> getRequireWiFiSetting() async => RequireWiFi
.values[await methodChannel.invokeMethod('getRequireWiFiSetting') ?? 0];
@override
void updateNotification(Task task, TaskStatus? taskStatusOrNull) {
final notificationConfig = notificationConfigForTask(task);
if (notificationConfig != null) {
methodChannel.invokeMethod('updateNotification', [
jsonEncode(task.toJson()),
jsonEncode(notificationConfig.toJson()),
taskStatusOrNull?.index,
]);
}
}
/// Retrieve data that was not delivered to Dart, as a Map keyed by taskId
/// with one map for each taskId
///
/// Asks the native platform for locally stored data for resumeData,
/// status updates or progress updates.
/// ResumeData has a [ResumeData] json representation
/// StatusUpdates has a mixed Task & TaskStatus json representation 'taskStatus'
/// ProgressUpdates has a mixed Task & double json representation 'progress'
@override
Future<Map<String, String>> popUndeliveredData(Undelivered dataType) async {
final String jsonString = await switch (dataType) {
Undelivered.resumeData => methodChannel.invokeMethod('popResumeData'),
Undelivered.statusUpdates => methodChannel.invokeMethod(
'popStatusUpdates',
),
Undelivered.progressUpdates => methodChannel.invokeMethod(
'popProgressUpdates',
),
};
return Map.from(jsonDecode(jsonString));
}
@override
Future<String?> moveToSharedStorage(
String filePath,
SharedStorage destination,
String directory,
String? mimeType, {
bool asUriString = false,
}) => methodChannel.invokeMethod<String?>('moveToSharedStorage', [
filePath,
destination.index,
directory,
mimeType,
asUriString,
]);
@override
Future<String?> pathInSharedStorage(
String filePath,
SharedStorage destination,
String directory, {
bool asUriString = false,
}) => methodChannel.invokeMethod<String?>('pathInSharedStorage', [
filePath,
destination.index,
directory,
asUriString,
]);
@override
Future<bool> openFile(Task? task, String? filePath, String? mimeType) async {
final result = await methodChannel.invokeMethod<bool>('openFile', [
if (task != null) jsonEncode(task.toJson()) else null,
filePath,
mimeType,
]);
return result ?? false;
}
@override
Future<String> platformVersion() async =>
(await methodChannel.invokeMethod<String>('platformVersion')) ?? '';
@override
Future<Duration> getTaskTimeout() async {
if (defaultTargetPlatform == TargetPlatform.android) {
final timeoutMillis =
await methodChannel.invokeMethod<int>('getTaskTimeout') ?? 0;
return Duration(milliseconds: timeoutMillis);
}
return const Duration(hours: 4); // on iOS, resource timeout
}
@override
Future<void> setForceFailPostOnBackgroundChannel(bool value) async {
await methodChannel.invokeMethod('forceFailPostOnBackgroundChannel', value);
}
@override
Future<String> testSuggestedFilename(
DownloadTask task,
String contentDisposition,
) async =>
await methodChannel.invokeMethod<String>('testSuggestedFilename', [
jsonEncode(task.toJson()),
contentDisposition,
]) ??
'';
@override
Future<(String, String)> configureItem((String, dynamic) configItem) async {
switch (configItem) {
case (Config.requestTimeout, final Duration? duration):
await NativeDownloader.methodChannel.invokeMethod(
'configRequestTimeout',
duration?.inSeconds,
);
case (Config.proxy, (final String address, final int port)):
await NativeDownloader.methodChannel.invokeMethod(
'configProxyAddress',
address,
);
await NativeDownloader.methodChannel.invokeMethod(
'configProxyPort',
port,
);
case (Config.proxy, false):
await NativeDownloader.methodChannel.invokeMethod(
'configProxyAddress',
null,
);
await NativeDownloader.methodChannel.invokeMethod(
'configProxyPort',
null,
);
case (Config.checkAvailableSpace, final int minimum):
assert(minimum > 0, 'Minimum available space must be in MB and > 0');
await NativeDownloader.methodChannel.invokeMethod(
'configCheckAvailableSpace',
minimum,
);
case (Config.checkAvailableSpace, false):
case (Config.checkAvailableSpace, Config.never):
await NativeDownloader.methodChannel.invokeMethod(
'configCheckAvailableSpace',
null,
);
case (
Config.holdingQueue,
(
final int? maxConcurrent,
final int? maxConcurrentByHost,
final int? maxConcurrentByGroup,
),
):
await NativeDownloader.methodChannel.invokeMethod(
'configHoldingQueue',
[
maxConcurrent ?? 1 << 20,
maxConcurrentByHost ?? 1 << 20,
maxConcurrentByGroup ?? 1 << 20,
],
);
case (Config.holdingQueue, Config.never):
case (Config.holdingQueue, false):
await NativeDownloader.methodChannel.invokeMethod(
'configHoldingQueue',
[],
);
case (Config.skipExistingFiles, final int value):
await NativeDownloader.methodChannel.invokeMethod(
'configSkipExistingFiles',
value,
);
case (Config.skipExistingFiles, Config.never):
case (Config.skipExistingFiles, false):
await NativeDownloader.methodChannel.invokeMethod(
'configSkipExistingFiles',
-1,
);
case (Config.skipExistingFiles, Config.always):
case (Config.skipExistingFiles, true):
await NativeDownloader.methodChannel.invokeMethod(
'configSkipExistingFiles',
0,
);
default:
return (
configItem.$1,
'not implemented',
); // this method did not process this configItem
}
return (configItem.$1, ''); // normal result
}
}
/// Android native downloader
final class AndroidDownloader extends NativeDownloader {
static final AndroidDownloader _singleton = AndroidDownloader._internal();
static int? _callbackDispatcherRawHandle;
factory AndroidDownloader() => _singleton;
AndroidDownloader._internal();
@override
Future<bool> enqueue(Task task) async {
await _registerCallbackDispatcher(task);
return super.enqueue(task);
}
@override
Future<List<bool>> enqueueAll(Iterable<Task> tasks) async {
for (final task in tasks) {
await _registerCallbackDispatcher(task);
}
return super.enqueueAll(tasks);
}
/// On Android, need to register [_callbackDispatcherRawHandle] upon first
/// encounter of a task with callbacks
Future<void> _registerCallbackDispatcher(Task task) async {
// on Android, need to register [_callbackDispatcherRawHandle] upon first
// encounter of a task with callbacks
if (task.options?.hasCallback == true &&
_callbackDispatcherRawHandle == null) {
final rawHandle = PluginUtilities.getCallbackHandle(
initCallbackDispatcher,
)?.toRawHandle();
if (rawHandle != null) {
final success = await NativeDownloader.methodChannel.invokeMethod<bool>(
'registerCallbackDispatcher',
rawHandle,
);
if (success == true) {
_callbackDispatcherRawHandle = rawHandle;
log.fine('Registered callbackDispatcher with handle $rawHandle');
} else {
log.warning('Could not register callbackDispatcher');
}
} else {
log.warning('Could not obtain rawHandle for initCallbackDispatcher');
}
}
}
@override
dynamic platformConfig({
dynamic globalConfig,
dynamic androidConfig,
dynamic iOSConfig,
dynamic desktopConfig,
}) => androidConfig;
@override
Future<(String, String)> configureItem((String, dynamic) configItem) async {
final superResult = await super.configureItem(configItem);
if (superResult.$2 != 'not implemented') {
return superResult;
}
switch (configItem) {
case (Config.runInForeground, final bool activate):
await NativeDownloader.methodChannel.invokeMethod(
'configForegroundFileSize',
activate ? 0 : -1,
);
case (Config.runInForeground, final String whenTo):
assert(
[Config.never, Config.always].contains(whenTo),
'${Config.runInForeground} expects one of ${[Config.never, Config.always]}',
);
await NativeDownloader.methodChannel.invokeMethod(
'configForegroundFileSize',
Config.argToInt(whenTo),
);
case (Config.runInForegroundIfFileLargerThan, final int fileSize):
await NativeDownloader.methodChannel.invokeMethod(
'configForegroundFileSize',
fileSize,
);
case (Config.bypassTLSCertificateValidation, final bool bypass):
if (bypass) {
if (kReleaseMode) {
throw ArgumentError(
'You cannot bypass certificate validation in release mode',
);
}
await NativeDownloader.methodChannel.invokeMethod(
'configBypassTLSCertificateValidation',
);
log.warning(
'TLS certificate validation is bypassed. This is insecure and cannot be '
'done in release mode',
);
} else {
throw ArgumentError(
'To undo bypassing the certificate validation, '
'restart and leave out the "configBypassCertificateValidation" configuration',
);
}
case (Config.useCacheDir, final String whenTo):
assert(
[Config.never, Config.whenAble, Config.always].contains(whenTo),
'${Config.useCacheDir} expects one of ${[Config.never, Config.whenAble, Config.always]}',
);
await NativeDownloader.methodChannel.invokeMethod(
'configUseCacheDir',
Config.argToInt(whenTo),
);
case (Config.useExternalStorage, final String whenTo):
assert(
[Config.never, Config.always].contains(whenTo),
'${Config.useExternalStorage} expects one of ${[Config.never, Config.always]}',
);
await NativeDownloader.methodChannel.invokeMethod(
'configUseExternalStorage',
Config.argToInt(whenTo),
);
Task.useExternalStorage = whenTo == Config.always;
case (Config.tempFilePath, final String path):
final cleanPath = (path.isNotEmpty && path != Config.never)
? path
: null;
await NativeDownloader.methodChannel.invokeMethod(
'configTempFilePath',
cleanPath,
);
case (Config.tempFilePath, null):
case (Config.tempFilePath, false):
await NativeDownloader.methodChannel.invokeMethod(
'configTempFilePath',
null,
);
default:
return (
configItem.$1,
'not implemented',
); // this method did not process this configItem
}
return (configItem.$1, ''); // normal result
}
}
/// iOS native downloader
final class IOSDownloader extends NativeDownloader {
static final IOSDownloader _singleton = IOSDownloader._internal();
factory IOSDownloader() => _singleton;
IOSDownloader._internal();
/// On iOS we immediately initialize the callback dispatcher and start
/// listening for callback messages from the native side
///
/// Whereas on Android, the initialization is done directly by the native side
/// using a DartExecutor
@override
Future<void> initialize() async {
initCallbackDispatcher();
return super.initialize();
}
@override
dynamic platformConfig({
dynamic globalConfig,
dynamic androidConfig,
dynamic iOSConfig,
dynamic desktopConfig,
}) => iOSConfig;
@override
Future<(String, String)> configureItem((String, dynamic) configItem) async {
final superResult = await super.configureItem(configItem);
if (superResult.$2 != 'not implemented') {
return superResult;
}
switch (configItem) {
case (Config.resourceTimeout, final Duration? duration):
await NativeDownloader.methodChannel.invokeMethod(
'configResourceTimeout',
duration?.inSeconds,
);
case (Config.localize, final Map<String, String>? translation):
await NativeDownloader.methodChannel.invokeMethod(
'configLocalize',
translation,
);
case (Config.excludeFromCloudBackup, final dynamic exclude):
assert(
exclude is bool || [Config.always, Config.never].contains(exclude),
'${Config.excludeFromCloudBackup} expects one of ${['true', 'false', Config.never, Config.always]}',
);
final boolValue = (exclude == true || exclude == Config.always);
await NativeDownloader.methodChannel.invokeMethod(
'configExcludeFromCloudBackup',
boolValue,
);
default:
return (
configItem.$1,
'not implemented',
); // this method did not process this configItem
}
return (configItem.$1, ''); // normal result
}
}
const _callbackChannel = MethodChannel(
'com.bbflight.background_downloader.callbacks',
);
/// Initialize the callbackDispatcher for native callbacks
///
/// Establishes the methodChannel through which the native side will send its
/// callBacks, and the listener that processes the different callback types.
///
/// This method is called directly from the native platform prior to using
/// the [_callbackChannel] to post the actual callback
@pragma('vm:entry-point')
void initCallbackDispatcher() {
WidgetsFlutterBinding.ensureInitialized();
_callbackChannel.setMethodCallHandler((MethodCall call) async {
switch (call.method) {
case 'beforeTaskStartCallback':
final taskJsonString = call.arguments as String;
final task = Task.createFromJson(jsonDecode(taskJsonString));
final callBack = task.options?.beforeTaskStartCallBack;
final taskStatusUpdate = await callBack?.call(task);
if (taskStatusUpdate == null) {
return null;
}
return jsonEncode(taskStatusUpdate.toJson());
case 'onTaskStartCallback':
case 'onAuthCallback':
final taskJsonString = call.arguments as String;
final task = Task.createFromJson(jsonDecode(taskJsonString));
final callBack = call.method == 'onTaskStartCallback'
? task.options?.onTaskStartCallBack
: task.options?.auth?.onAuthCallback;
final newTask = await callBack?.call(task);
if (newTask == null) {
return null;
}
return jsonEncode(newTask.toJson());
case 'onTaskFinishedCallback':
final taskUpdateJsonString = call.arguments as String;
final taskStatusUpdate = TaskStatusUpdate.fromJsonString(
taskUpdateJsonString,
);
final callBack = taskStatusUpdate.task.options?.onTaskFinishedCallBack;
await callBack?.call(taskStatusUpdate);
return null;
}
});
}
@@ -0,0 +1,370 @@
import 'dart:convert';
import 'dart:core';
import 'dart:io';
import 'dart:ui';
import 'package:http/http.dart' as http;
import 'package:logging/logging.dart';
import 'auth_callback.dart';
/// Provides an authentication handler for HTTP requests with support for access
/// and refresh tokens, customizable headers, and query parameters.
///
/// This class manages token-based authentication for HTTP requests, allowing
/// you to configure access and refresh tokens, as well as related headers and
/// query parameters. It supports automatic token refresh if an access token
/// expires and facilitates using a refresh token endpoint.
///
/// To use, :
/// - Create a [Task] as with a simple url, without query parameters, and
/// no headers. Query parameters and headers will be added based on the
/// [Auth] object, that you add to the task via [Task.options]. Include
/// [TaskOptions] with the [Auth] object as follows:
/// - Create an [Auth] object
/// - Add [accessQueryParams] containing all url query parameters. If your auth
/// uses query parameters for auth, then use `{accessToken}` to indicate where
/// the token should go, eg `{"auth":"{accessToken}"}` will add the access
/// token to the ur query parameter auth
/// - Add [accessHeaders] containing all headers required for the task. If your
/// auth uses headers, then use `{accessToken}` to indicate where the token
/// should go, eg `{"Authentication":" Bearer {accessToken}"}` will add a
/// typical authentication header for OAuth or JWT based requests.
/// - Add information for refresh, eg [refreshToken], [refreshUrl] if you want
/// to use the default handler
/// - If you use your own handler, add that as `onAuth` - otherwise the refresh
/// will be attempted using [defaultOnAuth]
///
/// For tasks that contain an [Auth] object, the background_downloader will
/// first check if the token is expired. If the token is expired, it will call
/// the `onAuth` callback, which should refresh the token by calling
/// [refreshToken], which will update the [Auth] associated with the task, then
/// return the modified task.
/// The downloader will substitute the `{accessToken}` placeholder with the
/// accessToken in both query parameters and headers before executing the task.
///
/// ### Key Properties
/// - [accessToken]: The current access token used for authentication.
/// - [refreshToken]: The refresh token used to obtain a new access token.
/// - [accessHeaders]: Headers to include in authenticated requests, with
/// optional `{accessToken}` placeholders for dynamic token insertion.
/// - [refreshHeaders]: Headers to include when making a refresh token request,
/// with option `{refreshToken}` placeholders for dynamic token insertion.
/// - [onAuth]: callback to be called before the task starts if access token is
/// expired. Callback should return a modified Task, or null to try with the
/// original
///
/// ### Key Methods
/// - [getAccessUri]: Returns the URI with authentication query parameters,
/// automatically refreshing the access token if needed.
/// - [getAccessHeaders]: Returns the headers to be used with the access
/// request, with the `{accessToken}` placeholder replaced
/// - [refreshAccessToken]: Refreshes the access token using the configured
/// refresh token and updates the expiry time. Called by [getAccessUri]
/// - [isTokenExpired]: Checks if the access token is nearing expiry.
///
/// ### Example
/// ```dart
/// final auth = Auth(
/// accessToken: 'initialAccessToken',
/// refreshToken: 'initialRefreshToken',
/// refreshUrl: 'https://example.com/token/refresh',
/// );
///
/// // Make a request with updated headers and query parameters
/// Uri uri = await auth.getAccessUri(url: 'https://example.com/data');
/// Map<String, String> headers = auth.getAccessHeaders();
/// ```
class Auth {
static final log = Logger('Auth');
/// The access token used for authentication
String? accessToken;
/// Headers to use for access requests. Can contain {accessToken} and
/// {refreshToken} which will be replaced by the actual tokens
Map<String, String> accessHeaders;
/// Query parameters to use for access requests. Can contain {accessToken} and
/// {refreshToken} which will be replaced by the actual tokens
Map<String, String> accessQueryParams;
/// Time when the access token expires
DateTime? accessTokenExpiryTime;
/// The refresh token used to obtain a new access token
String? refreshToken;
/// Headers to use for refresh requests. Can contain {accessToken} and
/// {refreshToken} which will be replaced by the actual tokens
Map<String, String> refreshHeaders;
/// Query parameters to use for refresh requests. Can contain {accessToken} and
/// {refreshToken} which will be replaced by the actual tokens
Map<String, String> refreshQueryParams;
/// The URL to use for refreshing the token
String? refreshUrl;
int? _onAuthRawHandle; // for callback
/// Creates an [Auth] object
///
/// [accessToken] is the initial access token
/// [accessHeaders] are headers to use for access requests
/// [accessQueryParams] are query parameters to use for access requests
/// [accessTokenExpiryTime] is the time when the access token expires
/// [refreshToken] is the initial refresh token
/// [refreshUrl] is the URL to use for refreshing the token
/// [refreshHeaders] are headers to use for refresh requests
/// [refreshQueryParams] are query parameters to use for refresh requests
/// [onAuth] is a callback to be called when the access token expires
Auth({
this.accessToken,
this.accessHeaders = const {},
this.accessQueryParams = const {},
this.accessTokenExpiryTime,
this.refreshToken,
this.refreshUrl,
this.refreshHeaders = const {},
this.refreshQueryParams = const {},
OnAuthCallback? onAuth,
}) : _onAuthRawHandle = onAuth != null
? PluginUtilities.getCallbackHandle(onAuth)?.toRawHandle()
: null;
/// Convert the Auth instance to JSON
Map<String, dynamic> toJson() => {
'accessToken': accessToken,
'accessHeaders': accessHeaders,
'accessQueryParams': accessQueryParams,
'accessTokenExpiryTime': accessTokenExpiryTime?.millisecondsSinceEpoch,
'refreshToken': refreshToken,
'refreshUrl': refreshUrl,
'refreshHeaders': refreshHeaders,
'refreshQueryParams': refreshQueryParams,
'onAuthRawHandle': _onAuthRawHandle,
};
/// Create an Auth instance from JSON
Auth.fromJson(Map<String, dynamic> json)
: accessToken = json['accessToken'],
accessHeaders = json['accessHeaders'] != null
? Map<String, String>.from(json['accessHeaders'])
: const {},
accessQueryParams = json['accessQueryParams'] != null
? Map<String, String>.from(json['accessQueryParams'])
: const {},
accessTokenExpiryTime = json['accessTokenExpiryTime'] != null
? DateTime.fromMillisecondsSinceEpoch(
json['accessTokenExpiryTime'] as int,
)
: null,
refreshToken = json['refreshToken'],
refreshUrl = json['refreshUrl'],
refreshHeaders = json['refreshHeaders'] != null
? Map<String, String>.from(json['refreshHeaders'])
: const {},
refreshQueryParams = json['refreshQueryParams'] != null
? Map<String, String>.from(json['refreshQueryParams'])
: const {},
_onAuthRawHandle = json['onAuthRawHandle'] as int?;
/// Returns the [OnAuthCallback] registered with this [Auth], or null
OnAuthCallback? get onAuthCallback => _onAuthRawHandle != null
? PluginUtilities.getCallbackFromHandle(
CallbackHandle.fromRawHandle(_onAuthRawHandle!),
) as OnAuthCallback
: null;
/// Set the [OnAuthCallback]
set onAuthCallback(OnAuthCallback? onAuth) =>
_onAuthRawHandle = onAuth != null
? PluginUtilities.getCallbackHandle(onAuth)?.toRawHandle()
: null;
/// Returns the Uri for accessing the resource
///
/// Refreshes the [accessToken] if required, and returns the original
/// [url] or [uri] with access query parameters. If headers are used to
/// authenticate, then call [getAccessHeaders] after this call, to make sure
/// they contain updated access tokens.
Future<Uri> getAccessUri({
String? url,
Uri? uri,
http.Client? httpClient,
String? refreshUrl,
Uri? refreshUri,
}) async {
if (uri == null && url == null) {
throw ArgumentError('Either uri or url must be provided');
}
final accessUri = uri ?? Uri.parse(url!);
if (isTokenExpired()) {
// make the refresh request
final (updatedAccesstoken, _) = await refreshAccessToken(
httpClient: httpClient,
refreshUrl: refreshUrl,
refreshUri: refreshUri,
);
if (!updatedAccesstoken) {
throw const HttpException('Could not refresh access token');
}
}
return addOrUpdateQueryParams(
uri: accessUri,
queryParams: getAccessQueryParams(),
);
}
/// Returns the URL string for accessing the resource
///
/// Refreshes the [accessToken] if required, and returns the original
/// [url] or [uri] with access query parameters. If headers are used to
/// authenticate, then call [getAccessHeaders] after this call, to make sure
/// they contain updated access tokens.
Future<String> getAccessUrl({
String? url,
Uri? uri,
http.Client? httpClient,
String? refreshUrl,
Uri? refreshUri,
}) async {
final accessUri = await getAccessUri(
url: url,
uri: uri,
httpClient: httpClient,
refreshUrl: refreshUrl,
refreshUri: refreshUri,
);
return accessUri.toString();
}
/// Refresh the [accessToken] by calling [refreshUrl] or [refreshUri] using
/// [httpClient], adding [refreshHeaders] and [refreshQueryParams]
/// to the request and obtaining the token from the json response
/// 'access_token' and potential 'expires_in'.
/// If the response contains 'refresh_token' that will be updated as well.
///
/// Returns (bool updatedAccessToken, bool updatedRefreshToken)
///
/// If neither [refreshUrl] nor [refreshUri] are given, the Auth object's
/// [Auth.refreshUrl] is used
Future<(bool, bool)> refreshAccessToken({
http.Client? httpClient,
String? refreshUrl,
Uri? refreshUri,
}) async {
if (refreshUrl == null && refreshUri == null && this.refreshUrl == null) {
throw ArgumentError(
'refreshUrl, refreshUri or Auth object refreshUrl required',
);
}
var updatedAccessToken = false;
var updatedRefreshToken = false;
final client = httpClient ?? http.Client();
try {
final selectedRefreshUri =
refreshUri ?? Uri.parse(refreshUrl ?? this.refreshUrl!);
final uri = addOrUpdateQueryParams(
uri: selectedRefreshUri,
queryParams: expandMap(refreshQueryParams),
);
final headers = expandMap(refreshHeaders);
headers['Content-type'] = 'application/json';
final response = await client.post(
uri,
headers: headers,
body: jsonEncode({
'grant_type': 'refresh_token',
'refresh_token': refreshToken,
}),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final newAuthToken = data['access_token'];
if (newAuthToken != null) {
accessToken = newAuthToken;
updatedAccessToken = true;
final newExpiresIn = data['expires_in'];
if (newExpiresIn != null) {
accessTokenExpiryTime = DateTime.now().add(
Duration(seconds: newExpiresIn),
);
}
} else {
log.fine(
'Failed to refresh access token: no access_token in response body.\n'
'Body is ${response.body}',
);
}
final newRefreshToken = data['refresh_token'];
if (newRefreshToken != null) {
refreshToken = newRefreshToken;
updatedRefreshToken = true;
}
} else {
log.fine('Failed to refresh token: ${response.statusCode}');
}
} catch (e) {
log.warning('Error refreshing token: $e');
}
return (updatedAccessToken, updatedRefreshToken);
}
/// Returns the headers specified for the access request, with the
/// template {accessToken} and {refreshToken} replaced
Map<String, String> getAccessHeaders() => expandMap(accessHeaders);
/// Returns the query params specified for the access request, with the
/// template {accessToken} and {refreshToken} replaced
Map<String, String> getAccessQueryParams() => expandMap(accessQueryParams);
/// Add/update the query parameters in this [url] or [uri] with [queryParams]
/// and return the new uri
Uri addOrUpdateQueryParams({
String? url,
Uri? uri,
Map<String, String> queryParams = const {},
}) {
if (uri == null && url == null) {
throw ArgumentError('Either uri or url must be provided');
}
final startUri = uri ?? Uri.parse(url!);
if (queryParams.isEmpty) {
return startUri;
}
final updatedQueryParams = Map<String, String>.from(
startUri.queryParameters,
);
queryParams.forEach((key, value) {
updatedQueryParams[key] = value;
});
return startUri.replace(queryParameters: updatedQueryParams);
}
/// Returns true if the [accessTokenExpiryTime is after now plus
/// the [bufferTime], otherwise returns false
bool isTokenExpired({Duration bufferTime = const Duration(seconds: 10)}) {
if (accessTokenExpiryTime == null) return false;
return DateTime.now().add(bufferTime).isAfter(accessTokenExpiryTime!);
}
/// Expands the [mapToExpand] by replacing {accessToken} and {refreshToken}
///
/// Returns the expanded map, without changing the original
Map<String, String> expandMap(Map<String, String> mapToExpand) {
final (access, refresh) = (accessToken, refreshToken);
final newMap = <String, String>{};
mapToExpand.forEach((key, value) {
var newValue = value;
if (access != null) {
newValue = newValue.replaceAll('{accessToken}', access);
}
if (refresh != null) {
newValue = newValue.replaceAll('{refreshToken}', refresh);
}
newMap[key] = newValue;
});
return newMap;
}
}
@@ -0,0 +1,26 @@
import 'dart:io';
import '../task.dart';
typedef OnAuthCallback = Future<Task?> Function(Task original);
/// Default onAuth callback
///
/// Called when a task starts and the task has an auth object and the
/// object indicated that the access token has expired.
/// The callback attempts to refresh the access token using the information
/// contained in the [Auth] object, and returns the task, which now contains an
/// updated [Auth] object
@pragma("vm:entry-point")
Future<Task?> defaultOnAuth(Task task) async {
final auth = task.options?.auth;
if (auth == null) {
throw ArgumentError('Task has no auth object');
}
final (updatedAccessToken, updatedRefreshToken) = await auth
.refreshAccessToken();
if (!updatedAccessToken) {
throw const HttpException('Could not refresh access token');
}
return task;
}
@@ -0,0 +1,146 @@
import 'dart:io';
/// Holds configuration options for mutual TLS (mTLS) client authentication.
///
/// Use with [Config.mTLS] to configure client certificate authentication for
/// HTTPS connections on desktop platforms.
///
/// Provide either file paths ([certificatePath], [privateKeyPath]) or raw bytes
/// ([certificateBytes], [privateKeyBytes]) for the client certificate and private key.
/// An optional private key [password] can be supplied if required.
///
/// An optional [host] can be specified to restrict this configuration to a specific host
/// (e.g. `'api.example.com'`). If [host] is `null`, the mTLS configuration applies globally
/// (or as the default for all hosts).
///
/// To reset/clear mTLS for a host or globally, pass an [MTLSConfig] instance with no credentials
/// (or `null`/`false` in configuration).
final class MTLSConfig {
/// Optional host (domain or IP) to which this mTLS configuration applies.
/// If null, applies to all hosts / default.
final String? host;
/// Path to client certificate / chain file (PEM or DER format).
final String? certificatePath;
/// Bytes of client certificate / chain file (PEM or DER format).
final List<int>? certificateBytes;
/// Path to private key file (PEM format).
final String? privateKeyPath;
/// Bytes of private key file (PEM format).
final List<int>? privateKeyBytes;
/// Optional password for decrypting the private key or certificate.
final String? password;
/// Optional path to trusted server / CA certificate file.
final String? serverCertificatePath;
/// Optional bytes of trusted server / CA certificate file.
final List<int>? serverCertificateBytes;
/// Creates an [MTLSConfig] instance.
///
/// Asserts that parameters are legally combined (e.g. not providing both file path
/// and raw bytes for the same asset, and ensuring both certificate and key are provided
/// when configuring credentials).
const MTLSConfig({
this.host,
this.certificatePath,
this.certificateBytes,
this.privateKeyPath,
this.privateKeyBytes,
this.password,
this.serverCertificatePath,
this.serverCertificateBytes,
}) : assert(
!(certificatePath != null && certificateBytes != null),
'Cannot provide both certificatePath and certificateBytes',
),
assert(
!(privateKeyPath != null && privateKeyBytes != null),
'Cannot provide both privateKeyPath and privateKeyBytes',
),
assert(
!(serverCertificatePath != null && serverCertificateBytes != null),
'Cannot provide both serverCertificatePath and serverCertificateBytes',
),
assert(
((certificatePath != null || certificateBytes != null) &&
(privateKeyPath != null || privateKeyBytes != null)) ||
((certificatePath == null && certificateBytes == null) &&
(privateKeyPath == null && privateKeyBytes == null)),
'Both certificate and privateKey must be provided together, or both omitted for reset',
);
/// Returns true if client certificate and key credentials are specified.
bool get hasCredentials =>
(certificatePath != null || certificateBytes != null) &&
(privateKeyPath != null || privateKeyBytes != null);
/// Returns true if this configuration is a reset configuration (no credentials).
bool get isReset => !hasCredentials;
/// Applies this mTLS configuration to the given [context].
void applyToSecurityContext(SecurityContext context) {
if (certificatePath != null) {
context.useCertificateChain(certificatePath!, password: password);
} else if (certificateBytes != null) {
context.useCertificateChainBytes(certificateBytes!, password: password);
}
if (privateKeyPath != null) {
context.usePrivateKey(privateKeyPath!, password: password);
} else if (privateKeyBytes != null) {
context.usePrivateKeyBytes(privateKeyBytes!, password: password);
}
if (serverCertificatePath != null) {
context.setTrustedCertificates(
serverCertificatePath!,
password: password,
);
} else if (serverCertificateBytes != null) {
context.setTrustedCertificatesBytes(
serverCertificateBytes!,
password: password,
);
}
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MTLSConfig &&
runtimeType == other.runtimeType &&
host == other.host &&
certificatePath == other.certificatePath &&
_listEquals(certificateBytes, other.certificateBytes) &&
privateKeyPath == other.privateKeyPath &&
_listEquals(privateKeyBytes, other.privateKeyBytes) &&
password == other.password &&
serverCertificatePath == other.serverCertificatePath &&
_listEquals(serverCertificateBytes, other.serverCertificateBytes);
@override
int get hashCode =>
host.hashCode ^
certificatePath.hashCode ^
Object.hashAll(certificateBytes ?? []) ^
privateKeyPath.hashCode ^
Object.hashAll(privateKeyBytes ?? []) ^
password.hashCode ^
serverCertificatePath.hashCode ^
Object.hashAll(serverCertificateBytes ?? []);
}
bool _listEquals(List<int>? a, List<int>? b) {
if (a == null) return b == null;
if (b == null || a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
@@ -0,0 +1,116 @@
import 'dart:ui';
import '../models.dart';
import '../task.dart';
import 'auth.dart';
typedef BeforeTaskStartCallback = Future<TaskStatusUpdate?> Function(Task task);
typedef OnTaskStartCallback = Future<Task?> Function(Task original);
typedef OnTaskFinishedCallback = Future<void> Function(
TaskStatusUpdate taskStatusUpdate,
);
/// Holds various options related to the task that are not included in the
/// task's properties, as they are rare
class TaskOptions {
final int? _beforeTaskStartRawHandle;
final int? _onTaskStartRawHandle;
final int? _onTaskFinishedRawHandle;
/// The [Auth] object associated with this task
final Auth? auth;
/// Constructor for [TaskOptions], containing "native" callbacks:
///
/// * `beforeTaskStart`: a callback called before a task starts executing.
/// The callback receives the `Task` and returns `null` if the task should
/// continue, or a `TaskStatusUpdate` if it should not start - in which
/// case the `TaskStatusUpdate` is posted as the last state update for the task
/// * `onTaskStart`: a callback called before a task starts executing.
/// The callback receives the `Task` and returns `null` if it did not
/// change anything, or a modified `Task` if it needs to use a different
/// url or header. It is called after `onAuth` for token refresh, if that is set
/// * `onTaskFinished`: a callback called when the task has finished.
/// The callback receives the final `TaskStatusUpdate`.
/// * `auth`: an [Auth] object that facilitates management of authorization
/// tokens and refresh tokens, and includes an `onAuth` callback similar to
/// `onTaskStart`
TaskOptions({
BeforeTaskStartCallback? beforeTaskStart,
OnTaskStartCallback? onTaskStart,
OnTaskFinishedCallback? onTaskFinished,
this.auth,
}) : _beforeTaskStartRawHandle = beforeTaskStart != null
? PluginUtilities.getCallbackHandle(beforeTaskStart)?.toRawHandle()
: null,
_onTaskStartRawHandle = onTaskStart != null
? PluginUtilities.getCallbackHandle(onTaskStart)?.toRawHandle()
: null,
_onTaskFinishedRawHandle = onTaskFinished != null
? PluginUtilities.getCallbackHandle(onTaskFinished)?.toRawHandle()
: null {
assert(
beforeTaskStart == null || _beforeTaskStartRawHandle != null,
_notTopLevel('beforeTaskStart'),
);
assert(
onTaskStart == null || _onTaskStartRawHandle != null,
_notTopLevel('onTaskStart'),
);
assert(
onTaskFinished == null || _onTaskFinishedRawHandle != null,
_notTopLevel('onTaskFinished'),
);
}
static String _notTopLevel(String callbackName) =>
'$callbackName callback must be a top level or static function, '
'and marked with @pragma("vm:entry-point")';
/// Create the object from JSON
TaskOptions.fromJson(Map<String, dynamic> json)
: _beforeTaskStartRawHandle = json['beforeTaskStartRawHandle'] as int?,
_onTaskStartRawHandle = json['onTaskStartRawHandle'] as int?,
_onTaskFinishedRawHandle = json['onTaskFinishedRawHandle'] as int?,
auth = json['auth'] != null ? Auth.fromJson(json['auth']) : null;
/// Returns the [BeforeTaskStartCallback] registered with this [TaskOptions], or null
BeforeTaskStartCallback? get beforeTaskStartCallBack =>
_beforeTaskStartRawHandle != null
? PluginUtilities.getCallbackFromHandle(
CallbackHandle.fromRawHandle(_beforeTaskStartRawHandle),
) as BeforeTaskStartCallback
: null;
/// Returns the [OnTaskStartCallback] registered with this [TaskOptions], or null
OnTaskStartCallback? get onTaskStartCallBack => _onTaskStartRawHandle != null
? PluginUtilities.getCallbackFromHandle(
CallbackHandle.fromRawHandle(_onTaskStartRawHandle),
) as OnTaskStartCallback
: null;
/// Returns the [OnTaskFinishedCallback] registered with this [TaskOptions], or null
OnTaskFinishedCallback? get onTaskFinishedCallBack =>
_onTaskFinishedRawHandle != null
? PluginUtilities.getCallbackFromHandle(
CallbackHandle.fromRawHandle(_onTaskFinishedRawHandle),
) as OnTaskFinishedCallback
: null;
/// True if [TaskOptions] contains any callback
bool get hasCallback =>
beforeTaskStartCallBack != null ||
onTaskStartCallBack != null ||
onTaskFinishedCallBack != null ||
auth?.onAuthCallback != null;
/// Creates JSON map of this object
Map<String, dynamic> toJson() => {
'beforeTaskStartRawHandle': _beforeTaskStartRawHandle,
'onTaskStartRawHandle': _onTaskStartRawHandle,
'onTaskFinishedRawHandle': _onTaskFinishedRawHandle,
'auth': auth?.toJson(),
};
}
@@ -0,0 +1,144 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import 'native_downloader.dart';
enum PermissionType {
notifications,
androidSharedStorage,
iosAddToPhotoLibrary,
iosChangePhotoLibrary,
}
enum PermissionStatus { undetermined, denied, granted, partial, requestError }
/// Developer visible interface to the PermissionService
abstract interface class Permissions {
/// Request a permission; returns the [PermissionStatus]
///
/// Ensure that only one request is being handled at the same time,
/// i.e. wait for the result before requesting another permission
Future<PermissionStatus> request(PermissionType permissionType);
/// Get the auth status of a permission; returns the [PermissionStatus]
Future<PermissionStatus> status(PermissionType permissionType);
/// Returns true if the developer should show a rationale for
/// requesting this permission
Future<bool> shouldShowRationale(PermissionType permissionType);
}
/// Basic implementation of the PermissionsService
base class PermissionsService implements Permissions {
final log = Logger("PermissionService");
PermissionsService();
/// Creates a [PermissionsService] appropriate for this platform
factory PermissionsService.instance() => switch (defaultTargetPlatform) {
.android => AndroidPermissionsService(),
.iOS => IOSPermissionsService(),
.linux || .macOS || .windows => PermissionsService(),
_ => throw ArgumentError(
'Platform $defaultTargetPlatform is not supported',
),
};
@override
Future<PermissionStatus> request(PermissionType permissionType) =>
Future.value(PermissionStatus.granted);
@override
Future<bool> shouldShowRationale(PermissionType permissionType) =>
Future.value(false);
@override
Future<PermissionStatus> status(PermissionType permissionType) =>
Future.value(PermissionStatus.granted);
/// Process the permissionRequest result sent from the native side
/// (Android only)
void onPermissionRequestResult(PermissionStatus permissionStatus) {}
}
final class IOSPermissionsService extends PermissionsService {
IOSPermissionsService();
@override
Future<PermissionStatus> request(PermissionType permissionType) async {
final result = await NativeDownloader.methodChannel.invokeMethod<int>(
'requestPermission',
permissionType.index,
);
return result != null
? PermissionStatus.values[result]
: PermissionStatus.requestError;
}
@override
Future<PermissionStatus> status(PermissionType permissionType) async {
final result = await NativeDownloader.methodChannel.invokeMethod<int>(
'permissionStatus',
permissionType.index,
);
return result != null
? PermissionStatus.values[result]
: PermissionStatus.requestError;
}
}
final class AndroidPermissionsService extends IOSPermissionsService {
Completer<PermissionStatus>? permissionStatusCompleter;
@override
Future<PermissionStatus> request(PermissionType permissionType) async {
if (![
PermissionType.notifications,
PermissionType.androidSharedStorage,
].contains(permissionType)) {
return PermissionStatus.granted;
}
if (permissionStatusCompleter != null) {
log.warning(
'Permission request already in progress - failing this one immediately',
);
return PermissionStatus.requestError;
}
final request = Completer<PermissionStatus>();
permissionStatusCompleter = request;
try {
final waitForCompletion = await NativeDownloader.methodChannel
.invokeMethod<bool>('requestPermission', permissionType.index);
if (waitForCompletion == true) {
// The callback can clear the shared slot before invokeMethod replies.
return await request.future;
}
log.warning(
'Failure to request for permission - was it already granted?',
);
return PermissionStatus.requestError;
} finally {
// Release only this request's slot, including platform dispatch errors.
if (identical(permissionStatusCompleter, request)) {
permissionStatusCompleter = null;
}
}
}
@override
Future<bool> shouldShowRationale(PermissionType permissionType) async {
final result = await NativeDownloader.methodChannel.invokeMethod<bool>(
'shouldShowPermissionRationale',
permissionType.index,
);
return result ?? false;
}
@override
void onPermissionRequestResult(PermissionStatus permissionStatus) {
permissionStatusCompleter?.complete(permissionStatus);
permissionStatusCompleter = null;
}
}
@@ -0,0 +1,690 @@
import 'dart:async';
import 'dart:io';
import 'dart:isolate';
import 'package:flutter/services.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'base_downloader.dart';
import 'database.dart';
import 'localstore/localstore.dart';
import 'models.dart';
import 'task.dart';
/// Interface for the persistent storage used to back the downloader
///
/// Defines 'store', 'retrieve', 'retrieveAll' and 'remove' methods for:
/// - [TaskRecord]s, keyed by taskId
/// - paused [Task]s, keyed by taskId
/// - [ResumeData], keyed by taskId
///
/// Each of the objects has a toJson method and can be created using
/// fromJson (use .createFromJson for [Task] objects)
///
/// Also defined methods to allow migration from one database version to another
abstract interface class PersistentStorage {
/// Store a [TaskRecord], keyed by taskId
Future<void> storeTaskRecord(TaskRecord record);
/// Retrieve [TaskRecord] with [taskId], or null if not found
Future<TaskRecord?> retrieveTaskRecord(String taskId);
/// Retrieve all [TaskRecord]
Future<List<TaskRecord>> retrieveAllTaskRecords();
/// Remove [TaskRecord] with [taskId] from storage. If null, remove all
Future<void> removeTaskRecord(String? taskId);
/// Store a paused [task], keyed by taskId
Future<void> storePausedTask(Task task);
/// Retrieve paused [Task] with [taskId], or null if not found
Future<Task?> retrievePausedTask(String taskId);
/// Retrieve all paused [Task]
Future<List<Task>> retrieveAllPausedTasks();
/// Remove paused [Task] with [taskId] from storage. If null, remove all
Future<void> removePausedTask(String? taskId);
/// Store [ResumeData], keyed by its taskId
Future<void> storeResumeData(ResumeData resumeData);
/// Retrieve [ResumeData] with [taskId], or null if not found
Future<ResumeData?> retrieveResumeData(String taskId);
/// Retrieve all [ResumeData]
Future<List<ResumeData>> retrieveAllResumeData();
/// Remove [ResumeData] with [taskId] from storage. If null, remove all
Future<void> removeResumeData(String? taskId);
/// Name and version number for this type of persistent storage
///
/// Used for database migration: this is the version represented by the code
(String, int) get currentDatabaseVersion;
/// Name and version number for database as stored
///
/// Used for database migration, may be 'older' than the code version
Future<(String, int)> get storedDatabaseVersion;
/// Initialize the database - only called when the [BaseDownloader]
/// is created with this object, which happens when the [FileDownloader]
/// singleton is instantiated, OR as part of a migration away from this
/// database type.
///
/// Migrates the data from stored name and version to the current
/// name and version, if needed
/// This call runs async with the rest of the initialization
Future<void> initialize();
}
enum _StorageCommand {
getStoredDatabaseVersion,
initialize,
storeTaskRecord,
retrieveTaskRecord,
retrieveAllTaskRecords,
removeTaskRecord,
storePausedTask,
retrievePausedTask,
retrieveAllPausedTasks,
removePausedTask,
storeResumeData,
retrieveResumeData,
retrieveAllResumeData,
removeResumeData,
retrieveAll,
clearCache,
}
/// Default implementation of [PersistentStorage] using Localstore package
///
/// Runs the actual [Localstore] based storage on a background isolate to
/// prevent jank on the main thread
class LocalStorePersistentStorage implements PersistentStorage {
static const taskRecordsPath =
_LocalStorePersistentStorageExecutor.taskRecordsPath;
static const resumeDataPath =
_LocalStorePersistentStorageExecutor.resumeDataPath;
static const pausedTasksPath =
_LocalStorePersistentStorageExecutor.pausedTasksPath;
final log = Logger('LocalStorePersistentStorage');
SendPort? _sendPort;
final _responseCompleters = <int, Completer<dynamic>>{};
int _nextRequestId = 0;
@override
(String, int) get currentDatabaseVersion => ('Localstore', 1);
@override
Future<(String, int)> get storedDatabaseVersion async {
final result = await _sendRequest<List<dynamic>>(
_StorageCommand.getStoredDatabaseVersion,
[],
);
return (result[0] as String, result[1] as int);
}
Future<void>? _initializationFuture;
@override
Future<void> initialize() {
if (_sendPort != null) {
return Future.value();
}
_initializationFuture ??= _doInitialize();
return _initializationFuture!;
}
Future<void> _doInitialize() async {
try {
final receivePort = ReceivePort();
await Isolate.spawn(_isolateEntry, receivePort.sendPort);
_sendPort = await receivePort.first as SendPort;
// Listen for responses
final responsePort = ReceivePort();
_sendPort!.send(responsePort.sendPort); // Send response port to isolate
responsePort.listen(_handleResponse);
// Initialize the executor in the isolate
// pass the RootIsolateToken to allow background isolate to use platform channels
// for path_provider
final rootIsolateToken = RootIsolateToken.instance;
await _sendRequest(_StorageCommand.initialize, [rootIsolateToken]);
} catch (e) {
_initializationFuture = null; // allow retry
rethrow;
}
}
void _handleResponse(dynamic message) {
if (message is List && message.length >= 2) {
final requestId = message[0] as int;
final response = message[1];
final error = message.length > 2 ? message[2] : null;
final completer = _responseCompleters.remove(requestId);
if (completer != null) {
if (error != null) {
completer.completeError(error);
} else {
completer.complete(response);
}
}
}
}
Future<T> _sendRequest<T>(_StorageCommand method, List<dynamic> args) async {
if (_sendPort == null) {
await initialize();
}
final completer = Completer<T>();
final requestId = _nextRequestId++;
_responseCompleters[requestId] = completer;
_sendPort!.send([requestId, method, args]);
return completer.future;
}
@override
Future<void> storeTaskRecord(TaskRecord record) =>
_sendRequest(_StorageCommand.storeTaskRecord, [record.toJson()]);
@override
Future<TaskRecord?> retrieveTaskRecord(String taskId) async {
final result = await _sendRequest<TaskRecord?>(
_StorageCommand.retrieveTaskRecord,
[taskId],
);
return result;
}
@override
Future<List<TaskRecord>> retrieveAllTaskRecords() async {
final result = await _sendRequest<List<dynamic>>(
_StorageCommand.retrieveAllTaskRecords,
[],
);
return result.cast<TaskRecord>();
}
@override
Future<void> removeTaskRecord(String? taskId) =>
_sendRequest(_StorageCommand.removeTaskRecord, [taskId]);
@override
Future<void> storePausedTask(Task task) =>
_sendRequest(_StorageCommand.storePausedTask, [task.toJson()]);
@override
Future<Task?> retrievePausedTask(String taskId) async {
final result = await _sendRequest<Task?>(
_StorageCommand.retrievePausedTask,
[taskId],
);
return result;
}
@override
Future<List<Task>> retrieveAllPausedTasks() async {
final result = await _sendRequest<List<dynamic>>(
_StorageCommand.retrieveAllPausedTasks,
[],
);
return result.cast<Task>();
}
@override
Future<void> removePausedTask(String? taskId) =>
_sendRequest(_StorageCommand.removePausedTask, [taskId]);
@override
Future<void> storeResumeData(ResumeData resumeData) =>
_sendRequest(_StorageCommand.storeResumeData, [resumeData.toJson()]);
@override
Future<ResumeData?> retrieveResumeData(String taskId) async {
final result = await _sendRequest<ResumeData?>(
_StorageCommand.retrieveResumeData,
[taskId],
);
return result;
}
@override
Future<List<ResumeData>> retrieveAllResumeData() async {
final result = await _sendRequest<List<dynamic>>(
_StorageCommand.retrieveAllResumeData,
[],
);
return result.cast<ResumeData>();
}
@override
Future<void> removeResumeData(String? taskId) =>
_sendRequest(_StorageCommand.removeResumeData, [taskId]);
Future<Map<String, dynamic>> retrieveAll(String collection) async {
final result = await _sendRequest<Map<String, dynamic>>(
_StorageCommand.retrieveAll,
[collection],
);
return result;
}
Future<void> clearCache() => _sendRequest(_StorageCommand.clearCache, []);
}
// Entry point for the isolate
void _isolateEntry(SendPort mainSendPort) {
final receivePort = ReceivePort();
mainSendPort.send(receivePort.sendPort);
SendPort? responseSendPort;
_LocalStorePersistentStorageExecutor? executor;
receivePort.listen((message) async {
if (message is SendPort) {
responseSendPort = message;
} else if (message is List) {
final requestId = message[0] as int;
final method = message[1] as _StorageCommand;
final args = message[2] as List<dynamic>;
try {
dynamic result;
if (method == _StorageCommand.initialize) {
final token = args[0] as RootIsolateToken?;
if (token != null) {
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
}
if (token != null) {
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
}
executor = _LocalStorePersistentStorageExecutor();
await executor!.initialize();
executor!.log.fine(
'Initialized isolate with dbDir ${await Localstore.instance.databaseDirectory}',
);
result = null;
} else {
// All other methods require executor to be initialized
if (executor == null) {
throw StateError('Executor not initialized');
}
result = await _dispatch(executor!, method, args);
}
responseSendPort?.send([requestId, result]);
} catch (e) {
responseSendPort?.send([requestId, null, e.toString()]);
}
}
});
}
Future<dynamic> _dispatch(
_LocalStorePersistentStorageExecutor executor,
_StorageCommand method,
List<dynamic> args,
) => switch (method) {
_StorageCommand.getStoredDatabaseVersion =>
executor.storedDatabaseVersion.then((r) => [r.$1, r.$2]), // tuple to list
_StorageCommand.storeTaskRecord => executor.storeTaskRecord(
args[0] as Map<String, dynamic>,
),
_StorageCommand.retrieveTaskRecord => executor.retrieveTaskRecord(
args[0] as String,
),
_StorageCommand.retrieveAllTaskRecords => executor.retrieveAllTaskRecords(),
_StorageCommand.removeTaskRecord => executor.removeTaskRecord(
args[0] as String?,
),
_StorageCommand.storePausedTask => executor.storePausedTask(
args[0] as Map<String, dynamic>,
),
_StorageCommand.retrievePausedTask => executor.retrievePausedTask(
args[0] as String,
),
_StorageCommand.retrieveAllPausedTasks => executor.retrieveAllPausedTasks(),
_StorageCommand.removePausedTask => executor.removePausedTask(
args[0] as String?,
),
_StorageCommand.storeResumeData => executor.storeResumeData(
args[0] as Map<String, dynamic>,
),
_StorageCommand.retrieveResumeData => executor.retrieveResumeData(
args[0] as String,
),
_StorageCommand.retrieveAllResumeData => executor.retrieveAllResumeData(),
_StorageCommand.removeResumeData => executor.removeResumeData(
args[0] as String?,
),
_StorageCommand.retrieveAll => executor.retrieveAll(args[0] as String),
_StorageCommand.clearCache => executor.clearCache(),
_StorageCommand.initialize => throw StateError(
'Initialize should be handled in isolate entry',
),
};
/// The executor that runs in the isolate and does the actual work
class _LocalStorePersistentStorageExecutor {
final log = Logger('LocalStorePersistentStorageExecutor');
final _db = Localstore.instance;
final _illegalPathCharacters = RegExp(r'[\\/:*?"<>|]');
static const taskRecordsPath = 'backgroundDownloaderTaskRecords';
static const resumeDataPath = 'backgroundDownloaderResumeData';
static const pausedTasksPath = 'backgroundDownloaderPausedTasks';
static const metaDataCollection = 'backgroundDownloaderDatabase';
Future<void> storeTaskRecord(Map<String, dynamic> recordJson) async {
final taskId = recordJson['taskId'] as String;
await store(recordJson, taskRecordsPath, _safeId(taskId));
}
Future<TaskRecord?> retrieveTaskRecord(String taskId) async {
final json = await retrieve(taskRecordsPath, _safeId(taskId));
return json != null ? TaskRecord.fromJson(json) : null;
}
Future<List<TaskRecord>> retrieveAllTaskRecords() async {
final jsonMaps = await retrieveAll(taskRecordsPath);
return jsonMaps.values
.map((e) => TaskRecord.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<void> removeTaskRecord(String? taskId) =>
remove(taskRecordsPath, _safeIdOrNull(taskId));
Future<void> storePausedTask(Map<String, dynamic> taskJson) async {
final taskId = taskJson['taskId'] as String;
await store(taskJson, pausedTasksPath, _safeId(taskId));
}
Future<Task?> retrievePausedTask(String taskId) async {
final json = await retrieve(pausedTasksPath, _safeId(taskId));
return json != null ? Task.createFromJson(json) : null;
}
Future<List<Task>> retrieveAllPausedTasks() async {
final jsonMaps = await retrieveAll(pausedTasksPath);
return jsonMaps.values
.map((e) => Task.createFromJson(e as Map<String, dynamic>))
.toList();
}
Future<void> removePausedTask(String? taskId) =>
remove(pausedTasksPath, _safeIdOrNull(taskId));
Future<void> storeResumeData(Map<String, dynamic> dataJson) async {
final taskMap = dataJson['task'] as Map<String, dynamic>;
final taskId = taskMap['taskId'] as String;
await store(dataJson, resumeDataPath, _safeId(taskId));
}
Future<ResumeData?> retrieveResumeData(String taskId) async {
final json = await retrieve(resumeDataPath, _safeId(taskId));
return json != null ? ResumeData.fromJson(json) : null;
}
Future<List<ResumeData>> retrieveAllResumeData() async {
final jsonMaps = await retrieveAll(resumeDataPath);
return jsonMaps.values
.map((e) => ResumeData.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<void> removeResumeData(String? taskId) =>
remove(resumeDataPath, _safeIdOrNull(taskId));
/// Stores [Map<String, dynamic>] formatted [document] in [collection] keyed under [identifier]
Future<void> store(
Map<String, dynamic> document,
String collection,
String identifier,
) async {
await _db.collection(collection).doc(identifier).set(document);
}
/// Returns [document] stored in [collection] under key [identifier]
/// as a [Map<String, dynamic>], or null if not found
Future<Map<String, dynamic>?> retrieve(
String collection,
String identifier,
) => _db.collection(collection).doc(identifier).get();
/// Returns all documents in collection as a [Map<String, dynamic>] keyed by the
/// document identifier, with the value a [Map<String, dynamic>] representing the document
Future<Map<String, dynamic>> retrieveAll(String collection) async =>
await _db.collection(collection).get() ?? {};
/// Removes document with [identifier] from [collection]
///
/// If [identifier] is null, removes all documents in the [collection]
Future<void> remove(String collection, [String? identifier]) async {
if (identifier == null) {
await _db.collection(collection).delete();
} else {
await _db.collection(collection).doc(identifier).delete();
}
}
/// Returns possibly modified id, safe for storing in the localStore
String _safeId(String id) => id.replaceAll(_illegalPathCharacters, '_');
/// Returns possibly modified id, safe for storing in the localStore, or null
/// if [id] is null
String? _safeIdOrNull(String? id) =>
id?.replaceAll(_illegalPathCharacters, '_');
Future<(String, int)> get storedDatabaseVersion async {
final metaData = await _db
.collection(metaDataCollection)
.doc('metaData')
.get();
return ('Localstore', (metaData?['version'] as num?)?.toInt() ?? 0);
}
(String, int) get currentDatabaseVersion => ('Localstore', 1);
Future<void> initialize() async {
final (currentName, currentVersion) = currentDatabaseVersion;
final (storedName, storedVersion) = await storedDatabaseVersion;
if (storedName != currentName) {
log.warning('Cannot migrate from database name $storedName');
return;
}
if (storedVersion == currentVersion) {
return;
}
log.fine(
'Migrating $currentName database from version $storedVersion to $currentVersion',
);
switch (storedVersion) {
case 0:
// move files from docDir to supportDir
final docDir = await getApplicationDocumentsDirectory();
final supportDir = await getApplicationSupportDirectory();
await Future.wait(
[resumeDataPath, pausedTasksPath, taskRecordsPath].map((path) async {
try {
final fromPath = join(docDir.path, path);
if (await Directory(fromPath).exists()) {
log.finest('Moving $path to support directory');
final toPath = join(supportDir.path, path);
await Directory(toPath).create(recursive: true);
final entities = await Directory(fromPath).list().toList();
await Future.wait(
entities.whereType<File>().map(
(file) => file.copy(join(toPath, basename(file.path))),
),
);
await Directory(fromPath).delete(recursive: true);
}
} catch (e) {
log.fine('Error migrating database for path $path: $e');
}
}),
);
default:
log.warning('Illegal starting version: $storedVersion');
}
await _db.collection(metaDataCollection).doc('metaData').set({
'version': currentVersion,
});
}
Future<void> clearCache() async {
Localstore.instance.clearCache();
}
}
/// Interface to migrate from one persistent storage to another
abstract interface class PersistentStorageMigrator {
/// Migrate data from one of the [migrationOptions] to the [toStorage]
///
/// If migration took place, returns the name of the migration option,
/// otherwise returns null
Future<String?> migrate(
List<String> migrationOptions,
PersistentStorage toStorage,
);
}
/// Migrates from [LocalStorePersistentStorage] to another [PersistentStorage]
class BasePersistentStorageMigrator implements PersistentStorageMigrator {
final log = Logger('PersistentStorageMigrator');
/// Create [BasePersistentStorageMigrator] object to migrate between persistent
/// storage solutions
///
/// [BasePersistentStorageMigrator] only migrates from:
/// * local_store (the default implementation of the database in
/// background_downloader).
///
/// To add other migrations, extend this class and inject it in the
/// [PersistentStorage] class that you want to migrate to.
///
/// See package background_downloader_sql for an implementation
/// that migrates to a SQLite based [PersistentStorage], including
/// migration from Flutter Downloader
BasePersistentStorageMigrator();
/// Migrate data from one of the [migrationOptions] to the [toStorage]
///
/// If migration took place, returns the name of the migration option,
/// otherwise returns null
///
/// This is the public interface to use in other [PersistentStorage]
/// solutions.
@override
Future<String?> migrate(
List<String> migrationOptions,
PersistentStorage toStorage,
) async {
for (final persistentStorageName in migrationOptions) {
try {
if (await migrateFrom(persistentStorageName, toStorage)) {
return persistentStorageName;
}
} on Exception catch (e, stacktrace) {
log.warning(
'Error attempting to migrate from $persistentStorageName: $e\n$stacktrace',
);
}
}
return null; // no migration
}
/// Attempt to migrate data from [persistentStorageName] to [toStorage]
///
/// Returns true if the migration was successfully executed, false if it
/// was not a viable migration
///
/// If extending the class, add your mapping from a migration option String
/// to a _migrateFrom... method that does your migration.
Future<bool> migrateFrom(
String persistentStorageName,
PersistentStorage toStorage,
) => switch (persistentStorageName.toLowerCase().replaceAll('_', '')) {
'localstore' => migrateFromLocalStore(toStorage),
_ => Future.value(false),
};
/// Migrate from a persistent storage to our database
///
/// Returns true if this migration took place
///
/// This is a generic migrator that copies from one storage to another, and
/// is used by the _migrateFrom... methods
Future<bool> migrateFromPersistentStorage(
PersistentStorage fromStorage,
PersistentStorage toStorage,
) async {
bool migratedSomething = false;
await fromStorage.initialize();
final pausedTasks = await fromStorage.retrieveAllPausedTasks();
if (pausedTasks.isNotEmpty) {
await Future.wait(pausedTasks.map((e) => toStorage.storePausedTask(e)));
migratedSomething = true;
}
final resumeData = await fromStorage.retrieveAllResumeData();
if (resumeData.isNotEmpty) {
await Future.wait(resumeData.map((e) => toStorage.storeResumeData(e)));
migratedSomething = true;
}
final taskRecords = await fromStorage.retrieveAllTaskRecords();
if (taskRecords.isNotEmpty) {
await Future.wait(taskRecords.map((e) => toStorage.storeTaskRecord(e)));
migratedSomething = true;
}
return migratedSomething;
}
/// Attempt to migrate from [LocalStorePersistentStorage]
///
/// Return true if successful. Successful migration removes the original
/// data
///
/// If extending this class, add a method like this that does the
/// migration by:
/// 1. Setting up the [PersistentStorage] object you want to migrate from
/// 2. Call [migrateFromPersistentStorage] to do the transfer from that
/// object to the new object, passed as [toStorage]
/// 3. Remove all traces of the [PersistentStorage] object you want to migrate
/// from
Future<bool> migrateFromLocalStore(PersistentStorage toStorage) async {
final localStore = LocalStorePersistentStorage();
if (await migrateFromPersistentStorage(localStore, toStorage)) {
// delete all paths related to LocalStore
final supportDir = await getApplicationSupportDirectory();
await Future.wait(
[
_LocalStorePersistentStorageExecutor.resumeDataPath,
_LocalStorePersistentStorageExecutor.pausedTasksPath,
_LocalStorePersistentStorageExecutor.taskRecordsPath,
_LocalStorePersistentStorageExecutor.metaDataCollection,
].map((collectionPath) async {
try {
final path = join(supportDir.path, collectionPath);
if (await Directory(path).exists()) {
log.finest('Removing directory $path for LocalStore');
await Directory(path).delete(recursive: true);
}
} catch (e) {
log.fine('Error deleting collection path $collectionPath: $e');
}
}),
);
return true; // we migrated a database
}
return false; // we did not migrate a database
}
}
@@ -0,0 +1,397 @@
import 'dart:async';
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'file_downloader.dart';
import 'models.dart';
import 'task.dart';
import 'uri/uri_helpers.dart';
/// Progress indicator for use with the [FileDownloader]
///
/// Configuration parameters:
/// [message] message to show for a single download. Templates {filename} and
/// {metadata} are replaced by a task's filename and metadata respectively
/// [collapsedMessage] message to show when multiple file downloads are
/// collapsed into a single row. The template {n} is replaced by the number
/// of tasks finished, and {total} is replaced by the total number of tasks
/// started in this batch. Both reset to 0 when all downloads finish.
/// In collapsed mode, progress is indicated as {n}/{total}
/// [showPauseButton] if true, shows a pause button if the task allows it
/// [showCancelButton] if true, shows a cancel button
/// [height] height of the [DownloadProgressIndicator], and of each row when
/// in expanded mode
/// [maxExpandable] maximum number of rows the indicator can expand to, with
/// each row showing one download in progress. If set to 1 (the default) the
/// indicator will not expand: if the number of downloads in progress exceeds
/// 1, the indicator will switch to a 'collapsed' state showing the
/// number of files finished out of the total started. It will stay in
/// 'collapsed' state until all downloads have finished. To start the
/// indicator in collapsed state (i.e. never show the download progress of a
/// single file), set [maxExpandable] to 0
/// [backgroundColor] background color for the widget
class DownloadProgressIndicator extends StatefulWidget {
const DownloadProgressIndicator(
this.updates, {
this.message = '{filename}',
this.collapsedMessage = '{n}/{total}',
this.showPauseButton = false,
this.showCancelButton = false,
this.height = 50,
this.maxExpandable = 1,
this.backgroundColor = Colors.grey,
super.key,
});
final Stream<TaskUpdate> updates;
final String message;
final String collapsedMessage;
final bool showPauseButton;
final bool showCancelButton;
final double height;
final int maxExpandable;
final Color backgroundColor;
@override
State<DownloadProgressIndicator> createState() =>
_DownloadProgressIndicatorState();
}
class _DownloadProgressIndicatorState extends State<DownloadProgressIndicator> {
StreamSubscription<TaskUpdate>? downloadUpdates;
final inProgress = <Task, (double, int)>{};
final pausedTasks = <Task>{};
final totalTasks = <Task>{};
final finishedTasks = <Task>{};
bool isExpanded = false;
bool isCollapsed = false;
@override
void initState() {
super.initState();
isCollapsed = widget.maxExpandable == 0;
downloadUpdates = widget.updates.listen((update) {
if (update is TaskStatusUpdate) {
switch (update.status) {
case .running:
totalTasks.add(update.task);
pausedTasks.remove(update.task);
inProgress[update.task] = (
0,
DateTime.now().millisecondsSinceEpoch,
);
case .waitingToRetry:
break;
case .paused:
pausedTasks.add(update.task);
default:
// task finished
_finish(update.task);
}
} else if (update is TaskProgressUpdate) {
switch (update.progress) {
case >= 0 && < 1:
// active, so add task to set and update progress
totalTasks.add(update.task);
pausedTasks.remove(update.task);
final previousInProgress = inProgress[update.task];
inProgress[update.task] = (
update.progress,
previousInProgress?.$2 ?? DateTime.now().millisecondsSinceEpoch,
);
case progressWaitingToRetry:
break;
case progressPaused:
pausedTasks.add(update.task);
default:
// task finished
_finish(update.task);
}
if (mounted) {
setState(() {});
}
}
});
}
@override
void dispose() {
super.dispose();
downloadUpdates?.cancel();
downloadUpdates = null;
}
/// Process a finished task
///
/// Remove the task from [inProgress] and [pausedTasks], add to [finishedTasks]
/// If [inProgress] is empty, also empty the sets and reset
/// the [isExpanded] and [isCollapsed] flags.
void _finish(Task task) {
inProgress.remove(task);
finishedTasks.add(task);
pausedTasks.remove(task);
if (inProgress.isEmpty) {
totalTasks.clear();
finishedTasks.clear();
isExpanded = false;
isCollapsed = widget.maxExpandable == 0;
}
}
@override
Widget build(BuildContext context) {
final activeTasks = inProgress.keys
.where((taskId) => inProgress[taskId]!.$1 >= 0)
.sorted((a, b) => inProgress[a]!.$2.compareTo(inProgress[b]!.$2));
final numActive = activeTasks.length;
if (numActive > 1) {
if (widget.maxExpandable > 1) {
isExpanded = true; // only resets when all downloads finish
} else {
isCollapsed = true; // only resets when all downloads finish
}
}
final itemsToShow = isExpanded
? min(numActive, widget.maxExpandable)
: isCollapsed
? min(1, numActive)
: numActive;
return AnimatedSize(
duration: const Duration(milliseconds: 200),
alignment: Alignment.bottomCenter,
child: switch (itemsToShow) {
0 => Container(height: 0),
1 =>
isCollapsed
? _CollapsedDownloadProgress(
finishedTasks.length,
totalTasks.length,
widget.collapsedMessage,
widget.height,
widget.backgroundColor,
)
: _DownloadProgressItem(
activeTasks.first,
inProgress[activeTasks.first]!.$1,
widget.message,
widget.showPauseButton,
widget.showCancelButton,
widget.height,
widget.backgroundColor,
pausedTasks,
),
_ => _ExpandedDownloadProgress(
activeTasks.take(widget.maxExpandable).toList(growable: false),
widget.message,
widget.height,
widget.backgroundColor,
inProgress,
),
},
);
}
}
final _fileNameRegEx = RegExp("""{filename}""", caseSensitive: false);
final _metadataRegEx = RegExp("""{metadata}""", caseSensitive: false);
/// Single file download progress widget
class _DownloadProgressItem extends StatelessWidget {
const _DownloadProgressItem(
this.task,
this.progress,
this.message,
this.showPauseButton,
this.showCancelButton,
this.height,
this.backgroundColor,
this.pausedTasks,
);
final Task task;
final double progress;
final String message;
final bool showPauseButton;
final bool showCancelButton;
final double height;
final Color backgroundColor;
final Set<Task> pausedTasks;
@override
Widget build(BuildContext context) {
final messageText = message
.replaceAll(
_fileNameRegEx,
unpack(task.filename).filename ?? task.filename,
)
.replaceAll(_metadataRegEx, task.metaData);
return Container(
height: height,
decoration: BoxDecoration(color: backgroundColor),
child: Padding(
padding: const EdgeInsets.only(left: 8, right: 8),
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: Text(
messageText,
style: Theme.of(context).textTheme.bodyMedium,
),
),
Expanded(
child: LinearProgressIndicator(
value: progress == 0 ? null : progress,
),
),
if (showPauseButton &&
task.allowPause &&
!pausedTasks.contains(task))
IconButton(
onPressed: () => FileDownloader().pause(task as DownloadTask),
icon: const Icon(Icons.pause),
color: Theme.of(context).primaryColor,
),
if (showPauseButton &&
task.allowPause &&
pausedTasks.contains(task))
IconButton(
onPressed: () => FileDownloader().resume(task as DownloadTask),
icon: const Icon(Icons.play_arrow),
color: Theme.of(context).primaryColor,
),
if (showCancelButton)
IconButton(
onPressed: () => FileDownloader().cancelTaskWithId(task.taskId),
icon: const Icon(Icons.cancel),
color: Theme.of(context).primaryColor,
),
],
),
),
);
}
}
/// Collapsed file download progress widget, showing the number of files
/// downloaded out of the total number of files downloaded as a progress
/// indicator
class _CollapsedDownloadProgress extends StatelessWidget {
const _CollapsedDownloadProgress(
this.numCompleted,
this.numTotal,
this.collapsedMessage,
this.height,
this.backgroundColor,
);
final int numCompleted;
final int numTotal;
final String collapsedMessage;
final double height;
final Color backgroundColor;
@override
Widget build(BuildContext context) {
final messageText = collapsedMessage
.replaceAll('{n}', '$numCompleted')
.replaceAll('{total}', '$numTotal');
final progress = numCompleted / numTotal;
return Container(
height: height,
decoration: BoxDecoration(color: backgroundColor),
child: Padding(
padding: const EdgeInsets.only(left: 8, right: 8),
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: Text(
messageText,
style: Theme.of(context).textTheme.bodyMedium,
),
),
Expanded(child: LinearProgressIndicator(value: progress)),
],
),
),
);
}
}
/// Expanded file download progress widget, showing multiple file download
/// progress indicators (up to expandable as defined in the core widget
class _ExpandedDownloadProgress extends StatelessWidget {
const _ExpandedDownloadProgress(
this.tasks,
this.message,
this.height,
this.backgroundColor,
this.inProgress,
);
final List<Task> tasks;
final String message;
final double height;
final Color backgroundColor;
final Map<Task, (double, int)> inProgress;
@override
Widget build(BuildContext context) => DecoratedBox(
decoration: BoxDecoration(
color: backgroundColor,
border: Border(top: BorderSide(color: Theme.of(context).dividerColor)),
),
child: Table(
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
columnWidths: const <int, TableColumnWidth>{
0: IntrinsicColumnWidth(),
1: FlexColumnWidth(),
},
children: tasks
.map(
(task) => TableRow(
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: Theme.of(context).dividerColor),
),
),
children: [
SizedBox(
height: height,
child: Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(left: 8, right: 8),
child: Text(
message
.replaceAll(
_fileNameRegEx,
unpack(task.filename).filename ?? task.filename,
)
.replaceAll(_metadataRegEx, task.metaData),
style: Theme.of(context).textTheme.bodyMedium,
),
),
),
),
Padding(
padding: const EdgeInsets.only(right: 8),
child: LinearProgressIndicator(value: inProgress[task]!.$1),
),
],
),
)
.toList(),
),
);
}
@@ -0,0 +1,46 @@
import 'dart:async';
import 'dart:collection';
/// A queue that processes jobs serially, one after another.
///
/// [J] is the type of the job data.
/// [R] is the type of the result returned by the processor.
class SerialJobQueue<J, R>(final Future<R> Function(J) _processor) {
final Queue<_Job<J, R>> _queue = Queue();
bool _isProcessing = false;
/// Adds data to the queue and returns a Future that completes
/// when this specific item has been processed.
Future<R> add(J data) {
final completer = Completer<R>();
_queue.add(_Job(data, completer));
// Trigger the processing loop if it's not already running
_processQueue();
return completer.future;
}
Future<void> _processQueue() async {
// If already running, do nothing. The loop will pick up the new item.
if (_isProcessing) return;
_isProcessing = true;
while (_queue.isNotEmpty) {
final job = _queue.removeFirst();
try {
// Await the processing to ensure order
final result = await _processor(job.data);
job.completer.complete(result);
} catch (e, stack) {
job.completer.completeError(e, stack);
}
}
_isProcessing = false;
}
}
// Simple container for the data and the completer
class _Job<J, R>(final J data, final Completer<R> completer);
@@ -0,0 +1,318 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:logging/logging.dart';
import '../file_downloader.dart';
import '../task.dart';
/// Interface allowing the [FileDownloader] to signal finished tasks to
/// a [TaskQueue]
abstract interface class TaskQueue {
/// Signals that [task] has finished
void taskFinished(Task task);
/// Pauses task processing in the queue.
///
/// If [tasks] or [group] are provided, pauses only those tasks.
/// If both are null, pauses all tasks.
///
/// Default implementation is a no-op to ensure backwards compatibility
/// with subclasses that don't override this method
Future<void> pauseAll({Iterable<DownloadTask>? tasks, String? group}) async {}
/// Resumes task processing in the queue
///
/// If [tasks] or [group] are provided, resumes only those tasks.
/// If both are null, resumes all tasks.
///
/// Default implementation is a no-op to ensure backwards compatibility
/// with subclasses that don't override this method
Future<void> resumeAll({
Iterable<DownloadTask>? tasks,
String? group,
}) async {}
}
/// TaskQueue that holds all information in memory
class MemoryTaskQueue implements TaskQueue {
static final _log = Logger('MemoryTaskQueue');
static const unlimited = 1 << 20;
/// Tasks waiting to be enqueued, in priority order
final waiting = PriorityQueue<Task>();
/// Max number of tasks running concurrently, default is unlimited
int maxConcurrent = unlimited;
/// Max number of active tasks connecting to the same host concurrently,
/// default is unlimited
int maxConcurrentByHost = unlimited;
/// Max number of active tasks with the same group concurrently,
/// default is unlimited
int maxConcurrentByGroup = unlimited;
/// Minimum interval between successive enqueues, set to avoid choking the
/// message loop when adding many tasks
Duration minInterval = const Duration(milliseconds: 20);
/// Set of tasks that have been enqueued with the FileDownloader
final enqueued = <Task>{}; // by TaskId
/// Active tasks count by hostname
final _activeByHost = <String, int>{};
/// Active tasks count by group
final _activeByGroup = <String, int>{};
var _readyForEnqueue = Completer();
final _enqueueErrorsStreamController = StreamController<Task>();
var _paused = false;
final _pausedTaskIds = <String>{};
MemoryTaskQueue() {
_readyForEnqueue.complete();
}
@override
Future<void> pauseAll({Iterable<DownloadTask>? tasks, String? group}) async {
if (tasks == null && group == null) {
_paused = true;
} else {
// pause specific tasks/groups
if (group != null) {
final tasksToPause = waiting.unorderedElements.where(
(task) => task.group == group && task is DownloadTask,
);
_pausedTaskIds.addAll(tasksToPause.map((e) => e.taskId));
}
if (tasks != null) {
_pausedTaskIds.addAll(tasks.map((e) => e.taskId));
}
}
}
@override
Future<void> resumeAll({Iterable<DownloadTask>? tasks, String? group}) async {
if (tasks == null && group == null) {
_paused = false;
_pausedTaskIds.clear();
} else {
// resume specific tasks/groups
if (group != null) {
final tasksToResume = waiting.unorderedElements.where(
(task) => task.group == group && task is DownloadTask,
);
for (final task in tasksToResume) {
_pausedTaskIds.remove(task.taskId);
}
}
if (tasks != null) {
for (final task in tasks) {
_pausedTaskIds.remove(task.taskId);
}
}
}
advanceQueue();
}
/// Add one [task] to the queue and advance the queue if possible
void add(Task task) {
waiting.add(task);
advanceQueue();
}
/// Add multiple [tasks] to the queue and advance the queue if possible
void addAll(Iterable<Task> tasks) {
waiting.addAll(tasks);
advanceQueue();
}
/// Remove all items in the queue. Does not affect tasks already enqueued
/// with the [FileDownloader]
void removeAll() => waiting.removeAll();
/// remove all waiting tasks matching [taskIds]. Does not affect tasks already enqueued
/// with the [FileDownloader]
void removeTasksWithIds(List<String> taskIds) {
for (final taskId in taskIds) {
final match = waiting.unorderedElements.firstWhereOrNull(
(task) => task.taskId == taskId,
);
if (match != null) {
waiting.remove(match);
}
}
}
/// remove all waiting tasks in [group]. Does not affect tasks already enqueued
/// with the [FileDownloader]
void removeTasksWithGroup(String group) {
final tasksToRemove = waiting.unorderedElements
.where((task) => task.group == group)
.toList(growable: false);
for (final task in tasksToRemove) {
waiting.remove(task);
}
}
/// Remove [task] from the queue. Does not affect tasks already enqueued
/// with the [FileDownloader]
void remove(Task task) => waiting.remove(task);
/// Reset the state of the [TaskQueue].
///
/// Clears the [waiting] queue and resets active tasks to 0
void reset({String? group}) {
if (group == null) {
removeAll();
enqueued.clear();
_activeByHost.clear();
_activeByGroup.clear();
} else {
removeTasksWithGroup(group);
final tasksToRemove = enqueued
.where((task) => task.group != group)
.toList(growable: false);
for (final task in tasksToRemove) {
if (enqueued.remove(task)) {
_decrementCounts(task);
}
}
}
}
/// Advance the queue if possible and ready, no-op if not
///
/// After the enqueue, [advanceQueue] is called again to ensure the
/// next item in the queue is enqueued, so the queue keeps going until
/// empty, or until it cannot enqueue another task
void advanceQueue() async {
if (_paused) {
return;
}
if (_readyForEnqueue.isCompleted) {
final task = getNextTask();
if (task == null) {
return;
}
_readyForEnqueue = Completer();
enqueued.add(task);
_incrementCounts(task);
enqueue(task).then((success) async {
if (!success) {
_log.warning(
'TaskId ${task.taskId} did not enqueue successfully and will be ignored',
);
if (_enqueueErrorsStreamController.hasListener) {
_enqueueErrorsStreamController.add(task);
}
}
await Future.delayed(minInterval);
_readyForEnqueue.complete();
});
_readyForEnqueue.future.then((_) => advanceQueue());
}
}
/// Get the next waiting task from the queue, or null if not available
Task? getNextTask() {
if (numActive >= maxConcurrent) {
return null;
}
final tasksThatHaveToWait = <Task>[];
while (waiting.isNotEmpty) {
final task = waiting.removeFirst();
if (_pausedTaskIds.contains(task.taskId)) {
tasksThatHaveToWait.add(task);
continue;
}
if (numActiveWithHostname(task.hostName) < maxConcurrentByHost &&
numActiveWithGroup(task.group) < maxConcurrentByGroup) {
waiting.addAll(tasksThatHaveToWait); // put back in queue
return task;
}
tasksThatHaveToWait.add(task);
}
waiting.addAll(tasksThatHaveToWait); // put back in queue
return null;
}
/// Enqueue the task to the [FileDownloader]
///
/// When using a [MemoryTaskQueue], do not use this method directly. Instead,
/// add your tasks to the queue using [add] and [addAll], and
/// let the [MemoryTaskQueue] manage the enqueueing.
Future<bool> enqueue(Task task) => FileDownloader().enqueue(task);
/// Task has finished, so remove from active and advance the queue to the
/// next task if the task was indeed managed by this queue
@override
void taskFinished(Task task) {
if (enqueued.remove(task)) {
_decrementCounts(task);
advanceQueue();
}
}
/// Number of active tasks, i.e. enqueued with the FileDownloader and
/// not yet finished
int get numActive => enqueued.length;
/// Returns number of tasks active with this host name
int numActiveWithHostname(String hostname) => _activeByHost[hostname] ?? 0;
/// Returns number of tasks active with this group
int numActiveWithGroup(String group) => _activeByGroup[group] ?? 0;
void _incrementCounts(Task task) {
try {
final host = task.hostName;
_activeByHost[host] = (_activeByHost[host] ?? 0) + 1;
} catch (_) {
// ignore invalid url
}
_activeByGroup[task.group] = (_activeByGroup[task.group] ?? 0) + 1;
}
void _decrementCounts(Task task) {
try {
final host = task.hostName;
if (_activeByHost.containsKey(host)) {
final newCount = _activeByHost[host]! - 1;
if (newCount <= 0) {
_activeByHost.remove(host);
} else {
_activeByHost[host] = newCount;
}
}
} catch (_) {
// ignore invalid url
}
if (_activeByGroup.containsKey(task.group)) {
final newCount = _activeByGroup[task.group]! - 1;
if (newCount <= 0) {
_activeByGroup.remove(task.group);
} else {
_activeByGroup[task.group] = newCount;
}
}
}
/// True if queue is empty
bool get isEmpty => waiting.isEmpty;
/// Number of tasks waiting to be enqueued
int get numWaiting => waiting.length;
/// Number of tasks waiting to be enqueued in [group]
int numWaitingWithGroup(String group) => waiting.unorderedElements
.where((element) => element.group == group)
.length;
/// Stream with [Task]s that failed to enqueue correctly
Stream<Task> get enqueueErrors => _enqueueErrorsStreamController.stream;
}
File diff suppressed because it is too large. Load diff
+315
View File
@@ -0,0 +1,315 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'exceptions.dart';
import 'file_downloader.dart';
import 'models.dart';
import 'task.dart';
/// The specific reason a transfer is currently held or waiting.
enum TransferHoldReason {
/// The transfer is not held (actively transferring, enqueued, completed, or failed).
none,
/// The transfer is held because the device has no internet connection.
offline,
/// The transfer is held because it requires Wi-Fi and the device is currently on a cellular connection.
waitingForWiFi,
}
/// Represents a single managed background file transfer (download, upload, or data task).
///
/// Returned by [Transfers.start], [Transfers.getOrStart],
/// and [Transfers.startAll].
///
/// Provides reactive [ValueNotifier] handles for Flutter UI data-binding,
/// broadcast streams for events, and awaitable [result] and [file] Futures.
class Transfer {
/// The underlying [Task] object associated with this transfer.
Task _task;
Task get task => _task;
/// The [FileDownloader] instance orchestrating this transfer.
final FileDownloader downloader;
/// Progress normalized strictly between `0.0` and `1.0` (or `null` if indeterminate).
///
/// Unlike lower-level progress streams, this notifier never receives negative status
/// sentinels; status changes are communicated separately via [statusNotifier].
final ValueNotifier<double?> progressNotifier = ValueNotifier<double?>(null);
/// Current task status ([TaskStatus.enqueued], [TaskStatus.running], [TaskStatus.complete], etc.).
final ValueNotifier<TaskStatus> statusNotifier = ValueNotifier<TaskStatus>(
TaskStatus.enqueued,
);
/// Current transfer speed in MB/s (or `-1.0` if unknown or not calculating).
final ValueNotifier<double> networkSpeedNotifier = ValueNotifier<double>(
-1.0,
);
/// Estimated time remaining for this transfer (or [Duration.zero] if unknown).
final ValueNotifier<Duration> timeRemainingNotifier = ValueNotifier<Duration>(
Duration.zero,
);
/// The [TaskException] associated with a failed transfer, or `null` if not failed.
final ValueNotifier<TaskException?> exceptionNotifier =
ValueNotifier<TaskException?>(null);
/// Reactive [ValueNotifier] indicating why this transfer is held (e.g. offline or waiting for Wi-Fi).
final ValueNotifier<TransferHoldReason> holdReasonNotifier =
ValueNotifier<TransferHoldReason>(TransferHoldReason.none);
/// Reactive [ValueNotifier] emitting the [NotificationType] whenever the user taps
/// a system notification associated with this transfer (or `null` if not tapped).
final ValueNotifier<NotificationType?> notificationTapNotifier =
ValueNotifier<NotificationType?>(null);
final StreamController<TaskUpdate> _updatesController =
StreamController<TaskUpdate>.broadcast();
Completer<TaskStatusUpdate> _resultCompleter = Completer<TaskStatusUpdate>();
Timer? _stallWatchdogTimer;
DateTime _lastProgressTime = DateTime.now();
/// Creates a new [Transfer] handle.
Transfer(
Task task, [
FileDownloader? downloader,
TaskStatus initialStatus = TaskStatus.enqueued,
double? initialProgress,
TaskException? initialException,
TransferHoldReason initialHoldReason = TransferHoldReason.none,
]) : _task = task,
downloader = downloader ?? FileDownloader() {
statusNotifier.value = initialStatus;
holdReasonNotifier.value = initialHoldReason;
if (initialProgress != null &&
initialProgress >= 0.0 &&
initialProgress <= 1.0) {
progressNotifier.value = initialProgress;
}
exceptionNotifier.value = initialException;
if (initialStatus.isFinalState) {
_resultCompleter.complete(
TaskStatusUpdate(task, initialStatus, initialException),
);
}
_setupStallWatchdog();
}
/// Unique task identifier.
String get taskId => task.taskId;
/// Current [TaskStatus] of this transfer.
TaskStatus get status => statusNotifier.value;
/// Current normalized progress (`0.0` to `1.0`, or `null`).
double? get progress => progressNotifier.value;
/// Current network speed in MB/s.
double get networkSpeed => networkSpeedNotifier.value;
/// Latest [TaskException], if the transfer failed.
TaskException? get exception => exceptionNotifier.value;
/// The current reason this transfer is held.
TransferHoldReason get holdReason => holdReasonNotifier.value;
/// True if the transfer is paused/held waiting for a Wi-Fi connection.
bool get isWaitingForWiFi => holdReason == TransferHoldReason.waitingForWiFi;
/// True if the transfer is paused/held because the device is offline.
bool get isOffline => holdReason == TransferHoldReason.offline;
/// The most recent [NotificationType] tapped on a system notification for this transfer, or `null`.
NotificationType? get notificationTap => notificationTapNotifier.value;
/// Combined broadcast stream of all [TaskStatusUpdate] and [TaskProgressUpdate] events for this transfer.
Stream<TaskUpdate> get updates => _updatesController.stream;
/// Stream emitting only [TaskStatusUpdate] events for this transfer.
Stream<TaskStatusUpdate> get statusUpdates =>
updates.where((u) => u is TaskStatusUpdate).cast<TaskStatusUpdate>();
/// Stream emitting only [TaskProgressUpdate] events for this transfer.
Stream<TaskProgressUpdate> get progressUpdates =>
updates.where((u) => u is TaskProgressUpdate).cast<TaskProgressUpdate>();
/// Completes when the task reaches a final state ([TaskStatus.complete],
/// [TaskStatus.failed], [TaskStatus.canceled], [TaskStatus.notFound]).
Future<TaskStatusUpdate> get result => _resultCompleter.future;
/// Resolves to the downloaded [File] upon successful completion.
///
/// Throws [TaskException] if the transfer fails, is canceled, or is not found.
Future<File> get file async {
if (task is DataTask) {
throw TaskException('DataTask does not produce a file on disk');
}
final statusUpdate = await result;
if (statusUpdate.status == TaskStatus.complete) {
final path = await statusUpdate.task.filePath();
return File(path);
}
throw statusUpdate.exception ??
TaskException('Task ended with status ${statusUpdate.status}');
}
/// Resolves to the server response body String upon completion, or null.
Future<String?> get responseBody async {
final statusUpdate = await result;
return statusUpdate.responseBody;
}
/// Explicitly allows this transfer to proceed over cellular data even if
/// `requiresWiFi` was set on the task.
Future<bool> allowCellular() async {
holdReasonNotifier.value = TransferHoldReason.none;
final updatedTask = task.copyWith(requiresWiFi: false);
_task = updatedTask;
if (updatedTask case final DownloadTask dTask when dTask.allowPause) {
if (await downloader.taskCanResume(dTask)) {
return downloader.resume(dTask);
}
}
return downloader.enqueue(updatedTask);
}
/// Pauses this transfer if supported.
Future<bool> pause() => switch (task) {
final DownloadTask dTask => downloader.pause(dTask),
_ => Future.value(false),
};
/// Resumes this transfer.
///
/// If this transfer can be resumed using saved resume data, resumes it.
/// Otherwise, re-enqueues the task with its retry count reset.
Future<bool> resume() async {
holdReasonNotifier.value = TransferHoldReason.none;
if (_resultCompleter.isCompleted) {
_resultCompleter = Completer<TaskStatusUpdate>();
}
if (task case final DownloadTask dTask when dTask.allowPause) {
if (await downloader.taskCanResume(dTask)) {
return downloader.resume(dTask);
}
}
final resetTask = task.copyWith(retriesRemaining: task.retries);
_task = resetTask;
return downloader.enqueue(resetTask);
}
/// Cancels this transfer.
Future<bool> cancel() {
holdReasonNotifier.value = TransferHoldReason.none;
return downloader.cancelTaskWithId(task.taskId);
}
/// Internal status update handler invoked by [FileDownloader].
void updateStatus(TaskStatusUpdate update) {
_task = update.task;
statusNotifier.value = update.status;
if (update.exception != null) {
exceptionNotifier.value = update.exception;
}
if (update.status == TaskStatus.enqueued ||
update.status == TaskStatus.running) {
if (_resultCompleter.isCompleted) {
_resultCompleter = Completer<TaskStatusUpdate>();
}
exceptionNotifier.value = null;
}
if (update.status == TaskStatus.running) {
_lastProgressTime = DateTime.now();
if (progressNotifier.value == null) {
progressNotifier.value = 0.0;
}
holdReasonNotifier.value = TransferHoldReason.none;
} else if (update.status == TaskStatus.complete) {
progressNotifier.value = 1.0;
holdReasonNotifier.value = TransferHoldReason.none;
} else if (update.status.isFinalState) {
holdReasonNotifier.value = TransferHoldReason.none;
} else if (update.status == TaskStatus.waitingToRetry) {
if (!downloader.isConnected) {
holdReasonNotifier.value = TransferHoldReason.offline;
} else if (task.requiresWiFi && !downloader.isWiFi) {
holdReasonNotifier.value = TransferHoldReason.waitingForWiFi;
}
}
if (!_updatesController.isClosed) {
_updatesController.add(update);
}
if (update.status.isFinalState && !_resultCompleter.isCompleted) {
_resultCompleter.complete(update);
}
}
/// Internal progress update handler invoked by [FileDownloader].
void updateProgress(TaskProgressUpdate update) {
_task = update.task;
_lastProgressTime = DateTime.now();
if (update.progress >= 0.0 && update.progress <= 1.0) {
progressNotifier.value = update.progress;
}
if (update.hasNetworkSpeed) {
networkSpeedNotifier.value = update.networkSpeed;
}
if (update.hasTimeRemaining) {
timeRemainingNotifier.value = update.timeRemaining;
}
if (!_updatesController.isClosed) {
_updatesController.add(update);
}
}
/// Internal notification tap handler invoked by [FileDownloader].
void onNotificationTap(NotificationType type) {
notificationTapNotifier.value = type;
}
void _setupStallWatchdog() {
final timeout = task.stallTimeout;
if (timeout == null) return;
_stallWatchdogTimer?.cancel();
_stallWatchdogTimer = Timer.periodic(const Duration(seconds: 5), (_) async {
if (statusNotifier.value == TaskStatus.running) {
final elapsed = DateTime.now().difference(_lastProgressTime);
if (elapsed >= timeout) {
_lastProgressTime = DateTime.now();
if (task case final DownloadTask dTask when dTask.allowPause) {
await pause();
}
await resume();
}
}
});
}
/// Disposes all internal streams, timers, and notifiers.
void dispose() {
_stallWatchdogTimer?.cancel();
_updatesController.close();
progressNotifier.dispose();
statusNotifier.dispose();
networkSpeedNotifier.dispose();
timeRemainingNotifier.dispose();
exceptionNotifier.dispose();
holdReasonNotifier.dispose();
notificationTapNotifier.dispose();
}
}
@@ -0,0 +1,41 @@
import 'task.dart';
/// Composable hints provided to [Task] to automatically tune
/// task parameters (such as priority, update modes, suggested filename, and pause resilience)
/// based on caller intent.
enum TransferHint {
/// The user is actively waiting for this transfer.
///
/// Sets `priority = 0` (activates Android 14+ User-Initiated Data Transfer / UIDT & iOS max priority 1.0) and `allowPause = true`.
///
/// **Important for Android 14+ (UIDT):** Android requires user-initiated data transfers
/// to display a user-visible notification while running. Therefore, tasks with [userInitiated]
/// should preferably include a notification configuration—either directly on [Task.notificationConfig]
/// or globally via [FileDownloader.configureNotification] or [FileDownloader.configureNotificationForGroup].
userInitiated,
/// The transfer is a large file (> 50-100 MB or takes minutes).
///
/// Ensures [Task.allowPause] is `true` (enabling Android 9-minute auto-resume cycles).
largeFile,
/// The transfer is small (or one of many bulk items).
///
/// Sets `updates = Updates.status` to eliminate MethodChannel progress chatter.
smallFile,
/// Low-priority background sync or maintenance transfer.
///
/// Sets `priority = 10` (lowest priority) so it does not compete with user-initiated transfers.
lowPriority,
/// Uses server `Content-Disposition` headers to determine the filename.
///
/// Sets [DownloadTask.filename] to [DownloadTask.suggestedFilename] (`'?'`).
useSuggestedFilename,
/// For an [UploadTask], uploads raw file bytes directly in the HTTP request body
/// (sets `post: 'binary'`), suitable for Amazon S3 presigned URLs, Google Cloud Storage,
/// and direct binary REST APIs, instead of multipart/form-data.
binaryUpload,
}
+624
View File
@@ -0,0 +1,624 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'base_downloader.dart';
import 'database.dart';
import 'exceptions.dart';
import 'file_downloader.dart';
import 'models.dart';
import 'task.dart';
import 'transfer.dart';
import 'transfer_hint.dart';
/// Manages active and completed [Transfer] objects, providing high-level methods
/// to start, batch start, reconnect to, and query background transfers.
///
/// Accessed via [FileDownloader.transfers].
class Transfers {
/// The parent [FileDownloader] coordinating this transfer manager.
final FileDownloader downloader;
final BaseDownloader _downloader;
final Map<String, Transfer> _transfers = {};
final Set<String> _registeredTransferGroups = {};
/// ValueNotifier containing the list of all currently tracked [Transfer] objects.
final ValueNotifier<List<Transfer>> notifier = ValueNotifier<List<Transfer>>(
[],
);
static bool _transferAutoCleanTriggered = false;
StreamSubscription<TaskRecord>? _databaseSubscription;
Transfers(this.downloader, this._downloader) {
_ensureDatabaseSubscription();
}
void _ensureDatabaseSubscription() {
_databaseSubscription ??= downloader.database.updates.listen(
_onDatabaseRecordUpdate,
);
}
void _ensureTransferAutoClean() {
if (!_transferAutoCleanTriggered &&
_downloader.isTrackingTasks &&
!downloader.database.autoClean) {
_transferAutoCleanTriggered = true;
downloader.database.cleanUp(autoClean: true);
}
}
Task _ensureProvidesStatusUpdates(Task task) => switch (task.updates) {
Updates.progress => task.copyWith(updates: Updates.statusAndProgress),
Updates.none => task.copyWith(updates: Updates.status),
Updates.status || Updates.statusAndProgress => task,
};
/// Enqueues a task and returns a [Transfer] object to manage and observe its progress.
///
/// If the task includes a [Task.notificationConfig] or [TransferHint.userInitiated],
/// notification behavior is configured automatically.
Future<Transfer> start(Task task) async {
_ensureDatabaseSubscription();
_ensureTransferAutoClean();
task = _ensureProvidesStatusUpdates(task);
final namespacedTask = downloader.withNamespacedGroup(task);
_ensureTransferGroupRegistered(namespacedTask.group);
if (task.notificationConfig != null) {
downloader.configureNotificationForTask(
namespacedTask,
running: task.notificationConfig!.running,
complete: task.notificationConfig!.complete,
error: task.notificationConfig!.error,
paused: task.notificationConfig!.paused,
canceled: task.notificationConfig!.canceled,
progressBar: task.notificationConfig!.progressBar,
tapOpensFile: task.notificationConfig!.tapOpensFile,
groupNotificationId: task.notificationConfig!.groupNotificationId,
);
} else if (task.transferHints?.contains(TransferHint.userInitiated) ==
true) {
final existingConfig = downloader.notificationConfigForTask(
namespacedTask,
);
if (existingConfig == null || existingConfig.running == null) {
final isUpload = task is UploadTask;
final action = isUpload ? 'Uploading' : 'Downloading';
downloader.configureNotificationForTask(
namespacedTask,
running: TaskNotification(action, '{progress}'),
complete: TaskNotification('$action complete', ''),
error: TaskNotification('$action failed', ''),
progressBar: true,
);
}
}
final cleanTask = downloader.withoutNamespacedGroup(namespacedTask);
final transfer = _getOrCreateTransfer(cleanTask);
final isOnline = _downloader.isConnected;
final isWiFi = _downloader.isWiFi;
final needsWiFi = task.requiresWiFi;
if (!isOnline) {
transfer.holdReasonNotifier.value = TransferHoldReason.offline;
} else if (needsWiFi && !isWiFi) {
transfer.holdReasonNotifier.value = TransferHoldReason.waitingForWiFi;
} else {
transfer.holdReasonNotifier.value = TransferHoldReason.none;
}
transfer.updateStatus(TaskStatusUpdate(cleanTask, TaskStatus.enqueued));
_notifyTransfersChanged();
await downloader.enqueue(namespacedTask);
return transfer;
}
/// Batch enqueues multiple [Task]s and returns their corresponding [Transfer] objects.
///
/// Optionally accepts an [onProgress] callback that is invoked whenever any of the
/// transfers in this batch complete or fail, reporting `(succeededCount, failedCount)`.
Future<List<Transfer>> startAll(
List<Task> tasks, {
void Function(int succeeded, int failed)? onProgress,
}) async {
if (tasks.isEmpty) return [];
_ensureDatabaseSubscription();
_ensureTransferAutoClean();
final namespacedTasks = <Task>[];
final resultTransfers = <Transfer>[];
final tasksToEnqueue = <Task>[];
for (var task in tasks) {
task = _ensureProvidesStatusUpdates(task);
final namespacedTask = downloader.withNamespacedGroup(task);
namespacedTasks.add(namespacedTask);
_ensureTransferGroupRegistered(namespacedTask.group);
if (task.notificationConfig != null) {
downloader.configureNotificationForTask(
namespacedTask,
running: task.notificationConfig!.running,
complete: task.notificationConfig!.complete,
error: task.notificationConfig!.error,
paused: task.notificationConfig!.paused,
canceled: task.notificationConfig!.canceled,
progressBar: task.notificationConfig!.progressBar,
tapOpensFile: task.notificationConfig!.tapOpensFile,
groupNotificationId: task.notificationConfig!.groupNotificationId,
);
}
final cleanTask = downloader.withoutNamespacedGroup(namespacedTask);
final transfer = _getOrCreateTransfer(cleanTask);
resultTransfers.add(transfer);
final isOnline = _downloader.isConnected;
final isWiFi = _downloader.isWiFi;
final needsWiFi = task.requiresWiFi;
if (!isOnline) {
transfer.holdReasonNotifier.value = TransferHoldReason.offline;
} else if (needsWiFi && !isWiFi) {
transfer.holdReasonNotifier.value = TransferHoldReason.waitingForWiFi;
} else {
transfer.holdReasonNotifier.value = TransferHoldReason.none;
}
transfer.updateStatus(TaskStatusUpdate(cleanTask, TaskStatus.enqueued));
tasksToEnqueue.add(namespacedTask);
}
if (tasksToEnqueue.isNotEmpty) {
final results = await downloader.enqueueAll(tasksToEnqueue);
for (var i = 0; i < tasksToEnqueue.length; i++) {
final success = results.length > i ? results[i] : false;
if (!success) {
final cleanTask = downloader.withoutNamespacedGroup(
tasksToEnqueue[i],
);
final transfer = _transfers[cleanTask.taskId];
transfer?.updateStatus(
TaskStatusUpdate(
cleanTask,
TaskStatus.failed,
TaskException('Failed to batch enqueue task on native platform'),
),
);
}
}
}
if (onProgress != null && resultTransfers.isNotEmpty) {
var succeeded = 0;
var failed = 0;
for (final transfer in resultTransfers) {
transfer.result.then((update) {
if (update.status == TaskStatus.complete) {
succeeded++;
} else {
failed++;
}
onProgress(succeeded, failed);
});
}
}
_notifyTransfersChanged();
return resultTransfers;
}
/// Returns an existing [Transfer] matching [task], or starts a new transfer.
Future<Transfer> getOrStart(
Task task, {
bool Function(Task existingTask)? matchBy,
bool reEnqueueIfFailed = true,
}) async {
_ensureTransferAutoClean();
task = _ensureProvidesStatusUpdates(task);
var existing = forTask(task, matchBy: matchBy);
if (existing == null && _downloader.isTrackingTasks) {
existing = await _findExistingInDatabase(task, matchBy: matchBy);
}
if (existing != null) {
if (existing.status == TaskStatus.complete ||
existing.status.isNotFinalState) {
return existing;
}
if (!reEnqueueIfFailed) {
return existing;
}
}
return start(task);
}
/// Returns existing [Transfer] handles matching [tasks] where available,
/// or starts transfers for those not already active or completed.
///
/// Tasks needing to be started are dispatched using batch enqueue.
Future<List<Transfer>> startOrGetAll(
List<Task> tasks, {
bool Function(Task existingTask)? matchBy,
bool reEnqueueIfFailed = true,
BatchProgressCallback? onProgress,
}) async {
_ensureTransferAutoClean();
final results = <Transfer>[];
final tasksToStart = <Task>[];
for (var task in tasks) {
task = _ensureProvidesStatusUpdates(task);
var existing = forTask(task, matchBy: matchBy);
if (existing == null && _downloader.isTrackingTasks) {
existing = await _findExistingInDatabase(task, matchBy: matchBy);
}
if (existing != null &&
(existing.status == TaskStatus.complete ||
existing.status.isNotFinalState ||
!reEnqueueIfFailed)) {
results.add(existing);
} else {
final namespacedTask = downloader.withNamespacedGroup(task);
final cleanTask = downloader.withoutNamespacedGroup(namespacedTask);
final transfer = _getOrCreateTransfer(cleanTask);
results.add(transfer);
tasksToStart.add(task);
}
}
if (tasksToStart.isNotEmpty) {
await startAll(tasksToStart);
}
if (onProgress != null && results.isNotEmpty) {
var succeeded = 0;
var failed = 0;
for (final transfer in results) {
if (transfer.status.isFinalState) {
if (transfer.status == TaskStatus.complete) {
succeeded++;
} else {
failed++;
}
} else {
transfer.result.then((update) {
if (update.status == TaskStatus.complete) {
succeeded++;
} else {
failed++;
}
onProgress(succeeded, failed);
});
}
}
onProgress(succeeded, failed);
}
return results;
}
/// Alias for [startOrGetAll].
Future<List<Transfer>> getOrStartAll(
List<Task> tasks, {
bool Function(Task existingTask)? matchBy,
bool reEnqueueIfFailed = true,
BatchProgressCallback? onProgress,
}) => startOrGetAll(
tasks,
matchBy: matchBy,
reEnqueueIfFailed: reEnqueueIfFailed,
onProgress: onProgress,
);
/// Finds an existing active or tracked [Transfer] matching [task].
Transfer? forTask(Task task, {bool Function(Task existingTask)? matchBy}) {
final cleanGroup = downloader.originalGroup(
downloader.namespacedGroup(task.group),
);
for (final transfer in _transfers.values) {
if (transfer.task.group != cleanGroup) continue;
if (matchBy != null) {
if (matchBy(transfer.task)) return transfer;
continue;
}
if (transfer.task.taskId == task.taskId) return transfer;
if (transfer.task.url == task.url &&
transfer.task.filename == task.filename &&
transfer.task.directory == task.directory &&
transfer.task.baseDirectory == task.baseDirectory) {
return transfer;
}
}
return null;
}
/// Returns the [Transfer] for [taskId], or null if not found.
Transfer? forId(String taskId) => _transfers[taskId];
/// Operator lookup for a [Transfer] by taskId.
Transfer? operator [](String taskId) => _transfers[taskId];
/// Removes a [Transfer] by its [taskId] from tracking.
///
/// If [dispose] is true (the default), disposes the [Transfer] instance.
/// Returns the removed [Transfer], or `null` if not found.
Transfer? remove(String taskId, {bool dispose = true}) {
final transfer = _transfers.remove(taskId);
if (transfer != null) {
if (dispose) {
transfer.dispose();
}
_notifyTransfersChanged();
}
return transfer;
}
/// Returns the first [Transfer] matching [url], or null.
Transfer? forUrl(String url) {
for (final transfer in _transfers.values) {
if (transfer.task.url == url) return transfer;
}
return null;
}
/// Returns all tracked [Transfer] objects, optionally filtered by [group].
List<Transfer> all({String? group}) {
if (group == null) {
return _transfers.values.toList();
}
final cleanGroup = downloader.originalGroup(
downloader.namespacedGroup(group),
);
return _transfers.values.where((t) => t.task.group == cleanGroup).toList();
}
/// Returns all active (non-final state) [Transfer] objects.
List<Transfer> active({String? group}) =>
all(group: group).where((t) => t.status.isNotFinalState).toList();
/// Returns all completed [Transfer] objects.
List<Transfer> completed({String? group}) =>
all(group: group).where((t) => t.status == TaskStatus.complete).toList();
void _ensureTransferGroupRegistered(String namespacedGroup) {
if (_registeredTransferGroups.contains(namespacedGroup)) return;
_registeredTransferGroups.add(namespacedGroup);
_downloader.trackTasks(namespacedGroup, true);
_downloader.groupTransferStatusCallbacks[namespacedGroup] = (rawUpdate) {
final cleanUpdate = TaskStatusUpdate(
downloader.withoutNamespacedGroup(rawUpdate.task),
rawUpdate.status,
rawUpdate.exception,
rawUpdate.responseBody,
rawUpdate.responseHeaders,
rawUpdate.responseStatusCode,
rawUpdate.mimeType,
rawUpdate.charSet,
);
_onTransferStatusUpdate(cleanUpdate);
};
_downloader.groupTransferProgressCallbacks[namespacedGroup] = (rawUpdate) {
final cleanUpdate = TaskProgressUpdate(
downloader.withoutNamespacedGroup(rawUpdate.task),
rawUpdate.progress,
rawUpdate.expectedFileSize,
rawUpdate.networkSpeed,
rawUpdate.timeRemaining,
);
_onTransferProgressUpdate(cleanUpdate);
};
_downloader.groupTransferNotificationTapCallbacks[namespacedGroup] =
(rawTask, notificationType) {
_onTransferNotificationTap(
downloader.withoutNamespacedGroup(rawTask),
notificationType,
);
};
}
void _onTransferStatusUpdate(TaskStatusUpdate update) {
final transfer = _transfers[update.task.taskId];
if (transfer != null) {
transfer.updateStatus(update);
}
_notifyTransfersChanged();
}
void _onTransferProgressUpdate(TaskProgressUpdate update) {
final transfer = _transfers[update.task.taskId];
if (transfer != null) {
transfer.updateProgress(update);
}
_notifyTransfersChanged();
}
void _onTransferNotificationTap(
Task task,
NotificationType notificationType,
) {
final transfer = _transfers[task.taskId];
if (transfer != null) {
transfer.onNotificationTap(notificationType);
}
}
Transfer _getOrCreateTransfer(Task cleanTask) {
var transfer = _transfers[cleanTask.taskId];
if (transfer == null) {
transfer = Transfer(cleanTask, downloader);
_transfers[cleanTask.taskId] = transfer;
_notifyTransfersChanged();
}
return transfer;
}
Transfer _createTransferFromRecord(TaskRecord record) {
final cleanTask = downloader.withoutNamespacedGroup(record.task);
var transfer = _transfers[cleanTask.taskId];
if (transfer != null) {
return transfer;
}
final normalizedProgress =
(record.progress >= 0.0 && record.progress <= 1.0)
? record.progress
: (record.status == TaskStatus.complete ? 1.0 : 0.0);
transfer = Transfer(
cleanTask,
downloader,
record.status,
normalizedProgress,
record.exception,
);
_transfers[cleanTask.taskId] = transfer;
_notifyTransfersChanged();
if (record.status.isNotFinalState) {
_ensureTransferGroupRegistered(record.task.group);
}
return transfer;
}
Future<Transfer?> _findExistingInDatabase(
Task task, {
bool Function(Task existingTask)? matchBy,
}) async {
final namespacedTask = downloader.withNamespacedGroup(task);
if (matchBy == null) {
final record = await downloader.database.recordForId(
namespacedTask.taskId,
);
if (record != null) {
return _createTransferFromRecord(record);
}
}
final cleanGroup = downloader.originalGroup(
downloader.namespacedGroup(task.group),
);
final allRecords = await downloader.database.allRecords();
for (final record in allRecords) {
final cleanRecordTask = downloader.withoutNamespacedGroup(record.task);
if (cleanRecordTask.group != cleanGroup) continue;
if (matchBy != null) {
if (matchBy(cleanRecordTask)) {
return _createTransferFromRecord(record);
}
continue;
}
if (cleanRecordTask.taskId == task.taskId ||
(cleanRecordTask.url == task.url &&
cleanRecordTask.filename == task.filename &&
cleanRecordTask.directory == task.directory &&
cleanRecordTask.baseDirectory == task.baseDirectory)) {
return _createTransferFromRecord(record);
}
}
return null;
}
void _onDatabaseRecordUpdate(TaskRecord record) {
if (downloader.hasNamespace &&
!record.task.group.startsWith('${downloader.namespace}.')) {
return;
}
final cleanTask = downloader.withoutNamespacedGroup(record.task);
final transfer = _transfers[cleanTask.taskId];
if (transfer != null) {
transfer.updateStatus(
TaskStatusUpdate(cleanTask, record.status, record.exception),
);
if (record.progress >= 0.0 && record.progress <= 1.0) {
transfer.updateProgress(
TaskProgressUpdate(
cleanTask,
record.progress,
record.expectedFileSize,
),
);
}
} else if (record.status.isNotFinalState) {
_createTransferFromRecord(record);
}
}
/// Rehydrates [Transfer] handles from the persistent database for previously
/// tracked tasks across app restarts.
Future<List<Transfer>> rehydrateFromDatabase({String? group}) async {
_ensureDatabaseSubscription();
if (!_downloader.isTrackingTasks) {
return [];
}
final namespacedGroupName = group != null
? downloader.namespacedGroup(group)
: null;
final records = await downloader.database.allRecords();
final rehydrated = <Transfer>[];
for (final record in records) {
if (namespacedGroupName != null &&
record.task.group != namespacedGroupName) {
continue;
}
if (downloader.hasNamespace &&
!record.task.group.startsWith('${downloader.namespace}.')) {
continue;
}
rehydrated.add(_createTransferFromRecord(record));
}
return rehydrated;
}
void _notifyTransfersChanged() {
notifier.value = List.unmodifiable(_transfers.values);
}
/// Clears internal transfer state, notifiers, and group listener registrations.
///
/// If [cancelActive] is true, cancels any active transfers before clearing.
Future<void> clear({bool cancelActive = false}) async {
if (cancelActive) {
await cancelAll();
}
_transferAutoCleanTriggered = false;
for (final transfer in _transfers.values) {
transfer.dispose();
}
_transfers.clear();
for (final group in _registeredTransferGroups) {
_downloader.groupTransferStatusCallbacks.remove(group);
_downloader.groupTransferProgressCallbacks.remove(group);
_downloader.groupTransferNotificationTapCallbacks.remove(group);
}
_registeredTransferGroups.clear();
notifier.value = [];
}
/// Cancels all currently active transfers, optionally filtered by [group].
Future<void> cancelAll({String? group}) async {
final activeTransfers = active(group: group);
await Future.wait(activeTransfers.map((t) => t.cancel()));
}
/// Cancels subscriptions and releases resources held by this [Transfers] manager.
void dispose() {
_databaseSubscription?.cancel();
_databaseSubscription = null;
for (final group in _registeredTransferGroups) {
_downloader.groupTransferStatusCallbacks.remove(group);
_downloader.groupTransferProgressCallbacks.remove(group);
_downloader.groupTransferNotificationTapCallbacks.remove(group);
}
_registeredTransferGroups.clear();
for (final transfer in _transfers.values) {
transfer.dispose();
}
_transfers.clear();
}
}
@@ -0,0 +1,42 @@
/// Packs [filename] and [uri] into a single String
///
/// use [unpack] to retrieve the filename and uri from the packed String
String pack(String filename, Uri uri) =>
':::$filename::::::${uri.toString()}:::';
/// Unpacks [packedString] into a (filename, uri). If this is not a packed
/// string, returns the original [packedString] as (filename and null) or,
/// if it is a Uri as (null and the uri)
({String? filename, Uri? uri}) unpack(String packedString) {
final regex = RegExp(r':::([\s\S]*?)::::::([\s\S]*?):::');
final match = regex.firstMatch(packedString);
if (match != null && match.groupCount == 2) {
final filename = match.group(1)!;
final uriString = match.group(2)!;
final uri = Uri.tryParse(uriString);
return (filename: filename, uri: uri?.hasScheme == true ? uri : null);
} else {
final uri = Uri.tryParse(packedString);
if (uri?.hasScheme == true) {
return (filename: null, uri: uri);
}
return (filename: packedString, uri: null);
}
}
/// Returns the Uri represented by [maybePacked], or null if the String is not a
/// valid Uri or packed Uri string.
///
/// [maybePacked] should be a full Uri string, or a packed String containing
/// a Uri (see [pack])
Uri? uriFromStringValue(String maybePacked) {
final (:filename, :uri) = unpack(maybePacked);
return uri;
}
/// Returns true if [maybePacked] is a valid Uri or packed Uri string.
///
/// [maybePacked] should be a full Uri string, or a packed String containing
/// a Uri (see [pack])
bool containsUri(String maybePacked) => uriFromStringValue(maybePacked) != null;
@@ -0,0 +1,292 @@
part of '../task.dart';
/// Interface for tasks that operate with [Uri] instead of file and directory
/// paths.
///
/// This allows the Uri version of the different tasks to be testable using
/// `task is UriTask`
abstract interface class UriTask {
/// Returns the URI of the directory associated with the task, or null
Uri? get directoryUri;
/// Returns the URI of the file associated with the task, or null
Uri? get fileUri;
}
/// Mixin that implements [UriTask] with the standard implementation, which
/// implements the getters for [directoryUri] and [fileUri], and overrides
/// the getters for [directory] and [filename] because the actual contents
/// of those properties contain encoded information that should not be
/// interpreted directly
base mixin _UriTaskMixin on Task implements UriTask {
@override
Uri? get directoryUri =>
super.directory.isNotEmpty ? Uri.tryParse(super.directory) : null;
@override
Uri? get fileUri {
final (:filename, :uri) = unpack(super.filename);
return uri;
}
@override
String get directory => '';
@override
String get filename {
final (:filename, :uri) = unpack(super.filename);
return filename ?? '';
}
/// Creates JSON map of this object
@override
Map<String, dynamic> toJson() => {
...super.toJson(),
'filename': super.filename, // replace with raw string
'directory': super.directory, // replace with raw string
};
}
final class UriDownloadTask extends DownloadTask with _UriTaskMixin {
/// Creates [UriDownloadTask] using a URI as the destination directory
///
/// For Android:
/// Content URIs are related to the Android Storage Framework that makes it
/// easier to get access to a file system location without the need for
/// app permissions, provided the user has chosen that location using a
/// file picker. They follow the content:// scheme, and can be obtained
/// by calling [UriUtils.pickDirectory].
///
/// For other platforms:
/// File URIs follow the file:// scheme and point to a destination on the
/// file system. A file:// URI is returned from [UriUtils.pickDirectory] on platforms
/// other than Android.
///
/// The [directoryUri] can be obtained using the
/// [directoryUri] getter. [directory] will always return the empty string
///
/// Note that the result of [Task.filePath] is undefined when using a URI, as
/// not all Uri types can be converted to a file name
UriDownloadTask({
super.taskId,
required super.url,
super.urlQueryParameters,
String? filename,
super.headers,
super.httpRequestMethod,
super.post,
required Uri directoryUri,
super.group,
super.updates,
super.requiresWiFi,
super.retries,
super.allowPause,
super.priority,
super.metaData,
super.displayName,
super.creationTime,
super.options,
super.transferHints,
super.notificationConfig,
super.stallTimeout,
}) : super(
baseDirectory: BaseDirectory.root,
directory: directoryUri.toString(),
filename: switch ((filename, directoryUri)) {
(DownloadTask.suggestedFilename, _) =>
DownloadTask.suggestedFilename,
(null, Uri(scheme: 'file')) => () {
final randomFilename = Random().nextInt(1 << 32).toString();
return pack(
randomFilename,
Uri.file(
'${directoryUri.path}/$randomFilename',
windows: Platform.isWindows,
),
);
}(),
(String filename, Uri(scheme: 'file')) => pack(
filename,
Uri.file(
'${directoryUri.path}/$filename',
windows: Platform.isWindows,
),
),
_ => filename,
},
) {
assert(
Task.allowedUriSchemes.contains(directoryUri.scheme),
'Directory URI scheme must be one of ${Task.allowedUriSchemes}',
);
if (_pathSeparatorRegExp.hasMatch(filename ?? '')) {
throw ArgumentError('Filename cannot contain path separators');
}
}
/// Creates [Task] object from JsonMap
///
/// Only used by subclasses. Use [Task.createFromJson] to create a properly
/// subclassed [Task] from the [json]
UriDownloadTask.fromJson(super.json) : super.fromJson();
@override
UriDownloadTask copyWith({
String? taskId,
String? url,
String? filename,
Map<String, String>? headers,
String? httpRequestMethod,
Object? post,
Uri? directoryUri,
String? directory,
BaseDirectory? baseDirectory,
String? group,
Updates? updates,
bool? requiresWiFi,
int? retries,
int? retriesRemaining,
bool? allowPause,
int? priority,
String? metaData,
String? displayName,
DateTime? creationTime,
TaskOptions? options,
Set<TransferHint>? transferHints,
TaskNotificationConfig? notificationConfig,
Duration? stallTimeout,
}) => UriDownloadTask(
taskId: taskId ?? this.taskId,
url: url ?? this.url,
filename: filename ?? this.filename,
headers: headers ?? this.headers,
httpRequestMethod: httpRequestMethod ?? this.httpRequestMethod,
post: post ?? postBody,
directoryUri: directoryUri ?? this.directoryUri ?? Uri.base,
group: group ?? this.group,
updates: updates ?? this.updates,
requiresWiFi: requiresWiFi ?? this.requiresWiFi,
retries: retries ?? this.retries,
allowPause: allowPause ?? this.allowPause,
priority: priority ?? this.priority,
metaData: metaData ?? this.metaData,
displayName: displayName ?? this.displayName,
creationTime: creationTime ?? this.creationTime,
options: options ?? this.options,
transferHints: transferHints ?? this.transferHints,
notificationConfig: notificationConfig ?? this.notificationConfig,
stallTimeout: stallTimeout ?? this.stallTimeout,
)..retriesRemaining = retriesRemaining ?? this.retriesRemaining;
@override
String get taskType => 'UriDownloadTask';
}
final class UriUploadTask extends UploadTask with _UriTaskMixin {
/// Creates [UploadTask] from a URI
///
/// The [fileUri] will can be obtained
/// using [fileUri] (do not access the [filename] property directly).
/// If a [filename] argument is supplied in this constructor,
/// it will be used, otherwise the filename will be derived from the URL
UriUploadTask({
required Uri fileUri,
super.taskId,
required super.url,
super.urlQueryParameters,
String? filename,
super.headers,
String? httpRequestMethod,
super.post,
super.fileField = 'file',
String? mimeType,
Map<String, String>? fields,
super.group,
super.updates,
super.requiresWiFi,
super.retries,
super.priority,
super.metaData,
super.displayName,
super.creationTime,
super.options,
super.transferHints,
super.notificationConfig,
super.stallTimeout,
}) : super(
baseDirectory: BaseDirectory.root,
filename: filename != null
? pack(filename, fileUri)
: fileUri.toString(),
httpRequestMethod: httpRequestMethod ?? 'POST',
mimeType: mimeType ?? 'application/octet-stream',
fields: fields ?? {},
) {
assert(
Task.allowedUriSchemes.contains(fileUri.scheme),
'URI scheme must be one of ${Task.allowedUriSchemes}',
);
}
/// Creates [Task] object from JsonMap
///
/// Only used by subclasses. Use [Task.createFromJson] to create a properly
/// subclassed [Task] from the [json]
UriUploadTask.fromJson(super.json) : super.fromJson();
@override
UriUploadTask copyWith({
Uri? fileUri,
String? taskId,
String? url,
String? filename,
Map<String, String>? headers,
String? httpRequestMethod,
Object? post,
String? fileField,
String? mimeType,
Map<String, String>? fields,
String? directory,
BaseDirectory? baseDirectory,
String? group,
Updates? updates,
bool? requiresWiFi,
int? priority,
int? retries,
int? retriesRemaining,
bool? allowPause,
String? metaData,
String? displayName,
DateTime? creationTime,
TaskOptions? options,
Set<TransferHint>? transferHints,
TaskNotificationConfig? notificationConfig,
Duration? stallTimeout,
}) => UriUploadTask(
fileUri: fileUri ?? this.fileUri ?? Uri.base,
taskId: taskId ?? this.taskId,
url: url ?? this.url,
filename: filename ?? this.filename,
headers: headers ?? this.headers,
httpRequestMethod: httpRequestMethod ?? this.httpRequestMethod,
post: post as String? ?? this.post,
fileField: fileField ?? this.fileField,
mimeType: mimeType ?? this.mimeType,
fields: fields ?? this.fields,
group: group ?? this.group,
updates: updates ?? this.updates,
requiresWiFi: requiresWiFi ?? this.requiresWiFi,
priority: priority ?? this.priority,
retries: retries ?? this.retries,
metaData: metaData ?? this.metaData,
displayName: displayName ?? this.displayName,
creationTime: creationTime ?? this.creationTime,
options: options ?? this.options,
transferHints: transferHints ?? this.transferHints,
notificationConfig: notificationConfig ?? this.notificationConfig,
stallTimeout: stallTimeout ?? this.stallTimeout,
)..retriesRemaining = retriesRemaining ?? this.retriesRemaining;
@override
String get taskType => 'UriUploadTask';
}
@@ -0,0 +1,603 @@
/// Object attached to the [FileDownloader]'s `utils` property, used to access
/// URI related utility functions
library;
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:logging/logging.dart';
import '../base_downloader.dart';
import '../models.dart';
import 'package:path/path.dart' as p;
import '../native_downloader.dart';
import '../task.dart';
/// Base implementation of the utilities related to File, Uri and filePath
/// manipulation.
///
/// Use the [withDownloader] factory constructor to get the appropriate subclass
/// for the platform you're on
sealed class UriUtils {
final log = Logger('UriUtils');
final BaseDownloader _downloader;
UriUtils(BaseDownloader downloader) : _downloader = downloader;
factory UriUtils.withDownloader(BaseDownloader downloader) =>
switch (downloader) {
AndroidDownloader() => _AndroidUriUtils(downloader),
IOSDownloader() => _IOSUriUtils(downloader),
_ => _DesktopUriUtils(downloader),
};
/// Opens a directory picker dialog and returns the selected directory's URI.
///
/// [startLocation] (optional) specifies a [SharedStorage] location to open the picker at.
/// [SharedStorage.images] and [SharedStorage.video] will launch the media
/// picker instead of the file picker, and allow selection of the respective media type.
/// [startLocationUri] (optional) specifies a URI to open the picker at.
/// Only one of [startLocation] or [startLocationUri] should be provided.
/// [persistedUriPermission] (optional, defaults to `false`) indicates whether to take persisted URI permission
/// for the selected directory, if the platform supports it.
///
/// Returns the selected directory's URI, or `null` if the user canceled the operation.
Future<Uri?> pickDirectory({
SharedStorage? startLocation,
Uri? startLocationUri,
bool persistedUriPermission = false,
});
/// Opens a file picker dialog and returns the Uri of the selected file,
/// or null if the user canceled the operation.
///
/// [startLocation] (optional) specifies a [SharedStorage] location to open the picker at.
/// Only .videos and .images will launch the media picker instead of the file picker.
/// [startLocationUri] (optional) specifies a URI to open the picker at.
/// Only one of [startLocation] or [startLocationUri] should be provided.
/// [allowedExtensions] (optional) specifies a list of file extensions to filter the picker by.
/// [persistedUriPermission] (optional, defaults to `false`) indicates whether to take persisted URI permission
/// for the selected files, if the platform supports it.
///
/// Returns the URI of the selected file, or `null` if the user canceled the operation.
Future<Uri?> pickFile({
SharedStorage? startLocation,
Uri? startLocationUri,
List<String>? allowedExtensions,
bool persistedUriPermission = false,
}) async {
final list = await pickFiles(
startLocation: startLocation,
startLocationUri: startLocationUri,
allowedExtensions: allowedExtensions,
persistedUriPermission: persistedUriPermission,
multipleAllowed: false,
);
return list?.isNotEmpty == true ? list!.first : null;
}
/// Opens a file picker dialog and returns a list of the selected files' URIs,
/// or null if the user canceled the operation.
///
/// [startLocation] (optional) specifies a [SharedStorage] location to open the picker at.
/// Only .videos and .images will launch the media picker instead of the file picker.
/// [startLocationUri] (optional) specifies a URI to open the picker at.
/// Only one of [startLocation] or [startLocationUri] should be provided.
/// [allowedExtensions] (optional) specifies a list of file extensions to filter the picker by.
/// [multipleAllowed] (optional, defaults to `false`) indicates whether to allow multiple file selection.
/// [persistedUriPermission] (optional, defaults to `false`) indicates whether to take persisted URI permission
/// for the selected files, if the platform supports it.
///
/// Returns a list of the selected files' URIs, or `null` if the user canceled the operation.
Future<List<Uri>?> pickFiles({
SharedStorage? startLocation,
Uri? startLocationUri,
List<String>? allowedExtensions,
bool multipleAllowed = false,
bool persistedUriPermission = false,
});
/// Creates a new directory with the given name within the specified parent directory.
///
/// [parentDirectoryUri] is the URI of the parent directory.
/// [newDirectoryName] is the name of the new directory to create.
/// [persistedUriPermission] (optional, defaults to `false`) indicates whether to take persisted URI permission
/// for the created directory, if the platform supports it.
///
/// Returns the URI of the newly created directory.
Future<Uri> createDirectory(
Uri parentDirectoryUri,
String newDirectoryName, {
bool persistedUriPermission = false,
});
/// Activate a previously accessed directory or file (applies previously
/// obtained permissions) and return the Uri, or null if this was not
/// possible
///
/// This is a no-op except on iOS, where it is required to re-activate the
/// permission obtained when setting `persistedUriPermission` to `true`,
/// when using the directory or file picker. In those instances, the returned
/// URI will have a 'urlbookmark' scheme instead of a file scheme, and that
/// bookmark contains security information. When you store that bookmark (for
/// later use) then you must make sure that you still have access to that
/// resource when - for example - using [getFileBytes] to get the data, or
/// to upload a file from a previously selected directory.
///
/// This method also converts a media:// scheme URI on iOS to a file:// URI,
/// allowing you to access (and delete) it. Media URIs on iOS are generated when
/// using [pickFiles] with a .images or .videos startingLocation (which
/// launches the media picker). Selected media is copied into the Application
/// Support directory, subdirectory "com.bbflight.downloader.media" and
/// returned as a media:// URI. To access the actual copied file,
/// use [activate]
Future<Uri?> activate(Uri uri) async => uri;
/// Retrieves the file data (bytes) for a given URI.
///
/// [uri] is the URI of the file.
///
/// Returns a [Uint8List] containing the file data, or `null` if an error occurred.
Future<Uint8List?> getFileBytes(Uri uri);
/// Copy a file from a Uri to a new destination.
///
/// The [destination] parameter can be a [File] object, a [String] with a
/// filePath, or a [Uri].
Future<Uri?> copyFile(Uri source, dynamic destination);
/// Move a file from a Uri to a new destination.
///
/// The [destination] parameter can be a [File] object, a [String] with a
/// filePath, or a [Uri].
Future<Uri?> moveFile(Uri source, dynamic destination);
/// Deletes the file at the given URI.
///
/// [uri] is the URI of the file to delete.
///
/// Returns `true` if the file was deleted successfully, `false` otherwise.
Future<bool> deleteFile(Uri uri);
/// Opens the file at [uri] with the given [mimeType].
///
/// Returns true if successful, false otherwise.
Future<bool> openFile(Uri uri, {String mimeType});
/// Move the file represented by the [task] to a shared storage
/// [destination] and potentially a [directory] within that destination. If
/// the [mimeType] is not provided we will attempt to derive it from the
/// [UriTask.fileUri]
///
/// Returns the Uri of the stored file, or null if not successful.
///
/// NOTE: on iOS, using [destination] [SharedStorage.images] or
/// [SharedStorage.video] adds the photo or video file to the Photos
/// library. This requires the user to grant permission, and requires the
/// "NSPhotoLibraryAddUsageDescription" key to be set in Info.plist. The
/// returned value is NOT a filePath but an identifier. If the full filepath
/// is required, follow the [moveToSharedStorage] call with a call to
/// [pathInSharedStorage], passing the identifier obtained from the call
/// to [moveToSharedStorage] as the filePath parameter. This requires the user to
/// grant additional permissions, and requires the "NSPhotoLibraryUsageDescription"
/// key to be set in Info.plist. The returned value is the actual file path
/// of the photo or video in the Photos Library.
///
/// Platform-dependent, not consistent across all platforms
Future<Uri?> moveToSharedStorage(
DownloadTask task,
SharedStorage destination, {
String directory = '',
String? mimeType,
}) async {
final uri = switch (task) {
final UriTask t => t.fileUri,
_ => Uri.file(await task.filePath()),
};
return uri != null
? await moveFileToSharedStorage(
uri,
destination,
directory: directory,
mimeType: mimeType,
)
: null;
}
/// Move the file represented by [fileUri] to a shared storage
/// [destination] and potentially a [directory] within that destination. If
/// the [mimeType] is not provided we will attempt to derive it from the
/// [fileUri] extension
///
/// Returns the URI of the stored file, or null if not successful
///
/// NOTE: on iOS, using [destination] [SharedStorage.images] or
/// [SharedStorage.video] adds the photo or video file to the Photos
/// library. This requires the user to grant permission, and requires the
/// "NSPhotoLibraryAddUsageDescription" key to be set in Info.plist. The
/// returned value is NOT a filePath but an identifier. If the full filepath
/// is required, follow the [moveToSharedStorage] call with a call to
/// [pathInSharedStorage], passing the identifier obtained from the call
/// to [moveToSharedStorage] as the filePath parameter. This requires the user to
/// grant additional permissions, and requires the "NSPhotoLibraryUsageDescription"
/// key to be set in Info.plist. The returned value is the actual file path
/// of the photo or video in the Photos Library.
///
/// Platform-dependent, not consistent across all platforms
Future<Uri?> moveFileToSharedStorage(
Uri fileUri,
SharedStorage destination, {
String directory = '',
String? mimeType,
}) async {
assert(
fileUri.scheme == 'file',
'uri.moveFileToSharedStorage requires a file scheme uri, got $fileUri',
);
final uriString = await _downloader.moveToSharedStorage(
fileUri.toString(),
destination,
directory,
mimeType,
asUriString: true,
);
return uriString != null ? Uri.tryParse(uriString) : null;
}
/// Returns the Uri of the file represented by [fileUri] in shared
/// storage [destination] and potentially a [directory] within that
/// destination.
///
/// Returns the path to the stored file, or null if not successful
///
/// See the documentation for [moveToSharedStorage] for special use case
/// on iOS for .images and .video
///
/// Platform-dependent, not consistent across all platforms
Future<Uri?> pathInSharedStorage(
Uri fileUri,
SharedStorage destination, {
String directory = '',
}) async {
assert(
fileUri.scheme == 'file',
'uri.pathInSharedStorage requires a file scheme uri, got $fileUri',
);
final uriString = await _downloader.pathInSharedStorage(
fileUri.toString(),
destination,
directory,
asUriString: true,
);
return uriString != null ? Uri.tryParse(uriString) : null;
}
/// Private helper method to determine the destination URI.
Uri _determineDestinationUri(dynamic destination) => switch (destination) {
File() => destination.uri,
String() => Uri.file(destination),
Uri() => destination,
_ => throw ArgumentError(
'Invalid destination type. Must be File, String, or Uri.',
),
};
}
final class _DesktopUriUtils extends UriUtils {
_DesktopUriUtils(super.downloader);
@override
Future<Uri?> pickDirectory({
SharedStorage? startLocation,
Uri? startLocationUri,
bool persistedUriPermission = false,
}) => throw UnimplementedError(
'pickDirectory not implemented for this platform. '
'Use the file_picker package and convert the resulting filePath '
'to a URI Uri.file(directoryPath, windows: Platform.isWindows)',
);
@override
Future<List<Uri>?> pickFiles({
SharedStorage? startLocation,
Uri? startLocationUri,
List<String>? allowedExtensions,
bool multipleAllowed = false,
bool persistedUriPermission = false,
}) => throw UnimplementedError(
'pickFiles not implemented for this platform. '
'Use the file_picker package and convert the resulting filePath '
'to a URI using Uri.file(filepath, windows: Platform.isWindows)',
);
@override
Future<Uri> createDirectory(
Uri parentDirectoryUri,
String newDirectoryName, {
bool persistedUriPermission = false,
}) async {
final parentPath = parentDirectoryUri.toFilePath(
windows: Platform.isWindows,
);
final cleanedSegments = newDirectoryName
.split(RegExp(r'[\\/]+'))
.where((segment) => segment.isNotEmpty)
.toList();
final fullPath = p.joinAll([parentPath, ...cleanedSegments]);
final createdDirectory = await Directory(fullPath).create(recursive: true);
return createdDirectory.uri;
}
@override
Future<Uint8List?> getFileBytes(Uri uri) async {
try {
final file = File.fromUri(uri);
if (await file.exists()) {
return await file.readAsBytes();
} else {
log.warning('File does not exist: $uri');
}
} catch (e) {
log.info('Error getting file bytes for $uri', e);
}
return null;
}
/// Private helper method to perform the file operation (copy or move).
Future<Uri?> _performFileOperation(
Uri source,
Uri destination,
Future<File> Function(String) operation,
) async {
if (!source.isScheme('file')) {
log.info('Source must be a file:// URI');
return null;
}
if (!destination.isScheme('file')) {
throw ArgumentError(
'Invalid destination type. Must be file:// Uri, is $destination',
);
}
try {
final destinationFile = File.fromUri(destination);
await destinationFile.parent.create(recursive: true);
await operation(destinationFile.path);
return destination;
} catch (e) {
log.info('Error during file operation from $source to $destination', e);
return null;
}
}
@override
Future<Uri?> copyFile(Uri source, dynamic destination) async {
final destinationUri = _determineDestinationUri(destination);
return _performFileOperation(
source,
destinationUri,
File.fromUri(source).copy,
);
}
@override
Future<Uri?> moveFile(Uri source, dynamic destination) async {
final destinationUri = _determineDestinationUri(destination);
return _performFileOperation(
source,
destinationUri,
File.fromUri(source).rename,
);
}
@override
Future<bool> deleteFile(Uri uri) async {
try {
final file = File.fromUri(uri);
if (await file.exists()) {
await file.delete();
return true;
} else {
log.warning('File does not exist: $uri');
}
} catch (e) {
log.warning('Error deleting file: $uri', e);
}
return false;
}
@override
Future<bool> openFile(Uri uri, {String? mimeType}) async {
try {
final filePath = uri.toFilePath();
return await _downloader.openFile(null, filePath, mimeType);
} catch (e) {
log.warning('Error opening file: $uri', e);
return false;
}
}
}
final class _NativeUriUtils extends UriUtils {
final _methodChannel = MethodChannel(
'com.bbflight.background_downloader.uriutils',
);
_NativeUriUtils(super.downloader);
@override
Future<Uri?> pickDirectory({
SharedStorage? startLocation,
Uri? startLocationUri,
bool persistedUriPermission = false,
}) async {
final uriString = (await _methodChannel.invokeMethod<String>(
'pickDirectory',
[
startLocation?.index,
startLocationUri?.toString(),
persistedUriPermission,
],
));
return (uriString != null) ? Uri.parse(uriString) : null;
}
@override
Future<List<Uri>?> pickFiles({
SharedStorage? startLocation,
Uri? startLocationUri,
List<String>? allowedExtensions,
bool multipleAllowed = false,
bool persistedUriPermission = false,
}) async {
final uriStrings = (await _methodChannel.invokeMethod('pickFiles', [
startLocation?.index,
startLocationUri?.toString(),
allowedExtensions,
multipleAllowed,
persistedUriPermission,
]));
// uriStrings can be a list of Strings or just one String, or null
return switch (uriStrings) {
final String uri => [Uri.parse(uri)],
final List<Object?>? uris =>
uris
?.where((e) => e != null)
.map((e) => Uri.parse(e as String))
.toList(growable: false),
_ => throw ArgumentError(
'pickFiles returned invalid value $uriStrings of type ${uriStrings.runtimeType}',
),
};
}
@override
Future<Uri> createDirectory(
Uri parentDirectoryUri,
String newDirectoryName, {
bool persistedUriPermission = false,
}) async {
final uriString = (await _methodChannel.invokeMethod<String>(
'createDirectory',
[parentDirectoryUri.toString(), newDirectoryName, persistedUriPermission],
));
return Uri.parse(uriString!);
}
/// Retrieves the file data (bytes) for a given URI.
///
/// [uri] is the URI of the file.
///
/// Returns a [Uint8List] containing the file data, or `null` if an error occurred.
@override
Future<Uint8List?> getFileBytes(Uri uri) async {
try {
final result = await _methodChannel.invokeMethod<Uint8List>(
'getFileBytes',
uri.toString(),
);
return result;
} catch (e) {
log.warning('Error reading file: $e');
return null;
}
}
@override
Future<Uri?> copyFile(Uri source, dynamic destination) async {
final destinationUri = _determineDestinationUri(destination);
try {
final result = await _methodChannel.invokeMethod<String>('copyFile', [
source.toString(),
destinationUri.toString(),
]);
return result != null ? Uri.parse(result) : null;
} catch (e) {
log.warning('Error copying file from $source to $destinationUri', e);
return null;
}
}
@override
Future<Uri?> moveFile(Uri source, dynamic destination) async {
final destinationUri = _determineDestinationUri(destination);
try {
final result = await _methodChannel.invokeMethod<String>('moveFile', [
source.toString(),
destinationUri.toString(),
]);
return result != null ? Uri.parse(result) : null;
} catch (e) {
log.warning('Error moving file from $source to $destinationUri', e);
return null;
}
}
@override
Future<bool> deleteFile(Uri uri) async {
try {
await _methodChannel.invokeMethod('deleteFile', uri.toString());
return true;
} catch (e) {
log.warning('Error deleting file at URI $uri: $e');
return false;
}
}
@override
Future<bool> openFile(Uri uri, {String? mimeType}) async {
try {
await _methodChannel.invokeMethod('openFile', [uri.toString(), mimeType]);
return true;
} catch (e) {
log.warning('Error opening file at URI $uri: $e');
return false;
}
}
}
final class _AndroidUriUtils extends _NativeUriUtils {
_AndroidUriUtils(super.downloader);
}
final class _IOSUriUtils extends _NativeUriUtils {
_IOSUriUtils(super.downloader);
@override
Future<Uri?> activate(Uri uri) async {
try {
final result = await _methodChannel.invokeMethod<String?>(
'activateUri',
uri.toString(),
);
return result != null ? Uri.parse(result) : uri;
} catch (e) {
log.fine('Error activating URI $uri: $e');
}
return null;
}
}
/// Extensions on String related to Uri and File
extension StringUriExtensions on String {
/// Converts this filePath to a file URI
Uri toFileUri() =>
Uri.file(this, windows: defaultTargetPlatform == TargetPlatform.windows);
}
/// Extensions on Uri related to File and String
extension UriExtensions on Uri {
/// Returns the File represented by this [uri]
File toFile() => File(
toFilePath(windows: defaultTargetPlatform == TargetPlatform.windows),
);
/// True if Uri scheme is file
bool get isFileUri => scheme == 'file';
/// True if Uri scheme is content
bool get isContentUri => scheme == 'content';
}
+175
View File
@@ -0,0 +1,175 @@
import 'dart:io';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as path;
import 'task.dart';
final _log = Logger('FileDownloader');
/// Return url String composed of the [url] and the
/// [urlQueryParameters], if given
String urlWithQueryParameters(
String url,
Map<String, String>? urlQueryParameters,
) {
if (urlQueryParameters == null || urlQueryParameters.isEmpty) {
return url;
}
final separator = url.contains('?') ? '&' : '?';
return '$url$separator${urlQueryParameters.entries.map((e) => '${e.key}=${e.value}').join('&')}';
}
/// Parses the range in a Range header, and returns a Pair representing
/// the range. The format needs to be "bytes=10-20"
///
/// A missing lower range is substituted with 0, and a missing upper
/// range with null. If the string cannot be parsed, returns (0, null)
(int, int?) parseRange(String rangeStr) {
final regex = RegExp(r'bytes=(\d*)-(\d*)');
final match = regex.firstMatch(rangeStr);
if (match == null) {
return (0, null);
}
final start = int.tryParse(match.group(1) ?? '') ?? 0;
final end = int.tryParse(match.group(2) ?? '');
return (start, end);
}
/// Returns the content length extracted from the [responseHeaders], or from
/// the [task] headers
int getContentLength(Map<String, String> responseHeaders, Task task) {
// if response provides contentLength, return it
final contentLength = int.tryParse(
responseHeaders['Content-Length'] ??
responseHeaders['content-length'] ??
'-1',
);
if (contentLength != null && contentLength != -1) {
return contentLength;
}
// try extracting it from Range header
final taskRangeHeader = task.headers['Range'] ?? task.headers['range'] ?? '';
final taskRange = parseRange(taskRangeHeader);
if (taskRange.$2 != null) {
final rangeLength = taskRange.$2! - taskRange.$1 + 1;
_log.finest(
'TaskId ${task.taskId} contentLength set to $rangeLength based on Range header',
);
return rangeLength;
}
// try extracting it from a special "Known-Content-Length" header
final knownLength =
int.tryParse(
task.headers['Known-Content-Length'] ??
task.headers['known-content-length'] ??
'-1',
) ??
-1;
if (knownLength != -1) {
_log.finest(
'TaskId ${task.taskId} contentLength set to $knownLength based on Known-Content-Length header',
);
} else {
_log.finest('TaskId ${task.taskId} contentLength undetermined');
}
return knownLength;
}
/// Returns a copy of the [task] with the [Task.filename] property changed
/// to the filename suggested by the server, or derived from the url, or
/// unchanged.
///
/// If [unique] is true, the filename is guaranteed not to already exist. This
/// is accomplished by adding a suffix to the suggested filename with a number,
/// e.g. "data (2).txt"
///
/// The server-suggested filename is obtained from the [responseHeaders] entry
/// "Content-Disposition" according to RFC6266, or the last path segment of the
/// URL, or leaves the filename unchanged
Future<DownloadTask> taskWithSuggestedFilename(
DownloadTask task,
Map<String, String> responseHeaders,
bool unique,
) {
/// Returns [DownloadTask] with a filename similar to the one
/// supplied, but unused.
///
/// If [unique], filename will sequence up in "filename (8).txt" format,
/// otherwise returns the [task]
Future<DownloadTask> uniqueFilename(DownloadTask task, bool unique) async {
if (!unique) {
return task;
}
final sequenceRegEx = RegExp(r'\((\d+)\)\.?[^.]*$');
final extensionRegEx = RegExp(r'\.[^.]*$');
var newTask = task;
var filePath = await newTask.filePath();
var exists = await File(filePath).exists();
while (exists) {
final extension =
extensionRegEx.firstMatch(newTask.filename)?.group(0) ?? '';
final match = sequenceRegEx.firstMatch(newTask.filename);
final newSequence = int.parse(match?.group(1) ?? "0") + 1;
final newFilename = match == null
? '${path.basenameWithoutExtension(newTask.filename)} ($newSequence)$extension'
: '${newTask.filename.substring(0, match.start - 1)} ($newSequence)$extension';
newTask = newTask.copyWith(filename: newFilename);
filePath = await newTask.filePath();
exists = await File(filePath).exists();
}
return newTask;
}
// start of main function
try {
final disposition = responseHeaders.entries
.firstWhere(
(element) => element.key.toLowerCase() == 'content-disposition',
)
.value;
// Try filename*=UTF-8'language'"encodedFilename"
final encodedFilenameRegEx = RegExp(
'filename\\*=\\s*([^\']+)\'([^\']*)\'"?([^"]+)"?',
caseSensitive: false,
);
var match = encodedFilenameRegEx.firstMatch(disposition);
if (match != null &&
match.group(1)?.isNotEmpty == true &&
match.group(3)?.isNotEmpty == true) {
try {
final suggestedFilename = match.group(1)?.toUpperCase() == 'UTF-8'
? Uri.decodeComponent(match.group(3)!)
: match.group(3)!;
return uniqueFilename(
task.copyWith(filename: suggestedFilename),
unique,
);
} on ArgumentError {
_log.finest(
'Could not interpret suggested filename (UTF-8 url encoded) ${match.group(3)}',
);
}
}
// Try filename="filename"
final plainFilenameRegEx = RegExp(
r'filename=\s*"?([^"]+)"?.*$',
caseSensitive: false,
);
match = plainFilenameRegEx.firstMatch(disposition);
if (match != null && match.group(1)?.isNotEmpty == true) {
return uniqueFilename(task.copyWith(filename: match.group(1)), unique);
}
} catch (_) {}
_log.finest('Could not determine suggested filename from server');
// Try filename derived from last path segment of the url
try {
final suggestedFilename = Uri.parse(task.url).pathSegments.last;
return uniqueFilename(task.copyWith(filename: suggestedFilename), unique);
} catch (_) {}
_log.finest('Could not parse URL pathSegment for suggested filename');
// if everything fails, return the task with unchanged filename
// except for possibly making it unique
return uniqueFilename(task, unique);
}
@@ -0,0 +1,113 @@
import 'dart:async';
import 'package:http/http.dart' as http;
import 'base_downloader.dart';
import 'models.dart';
import 'task.dart';
/// This is a non-functional stub
///
/// This file is conditionally imported when the compilation target for an
/// application is the Web platform. Without the conditional import, the
/// regular [DesktopDownloader] from desktop_downloader.dart would be used,
/// and it does not compile for Web. This stub prevents that compilation error,
/// but it is not an actual implementation for the Web.
final class DesktopDownloader extends BaseDownloader {
static const defaultMaxRedirects = 10;
static var httpClient = http.Client();
static Duration? requestTimeout;
static var proxy = <String, dynamic>{}; // 'address' and 'port'
static var bypassTLSCertificateValidation = false;
static var mtlsConfigs = <MTLSConfig>[];
/// Sets the HTTP client used for requests
///
/// This is a stub implementation
static void setHttpClient(
Duration? requestTimeout,
Map<String, dynamic> proxy,
bool bypassTLSCertificateValidation, [
List<MTLSConfig>? mtlsConfigs,
]) {
requestTimeout = requestTimeout;
proxy = proxy;
bypassTLSCertificateValidation = bypassTLSCertificateValidation;
}
/// Returns an [http.Client] configured for the given [url].
static http.Client httpClientForUrl(String? url) => httpClient;
/// Returns an [http.Client] configured for the given [host].
static http.Client httpClientForHost(String? host) => httpClient;
@override
Future<bool> cancelPlatformTasksWithIds(List<String> taskIds) {
throw UnimplementedError();
}
@override
Future<List<DownloadTask>> pauseAll({
Iterable<DownloadTask>? tasks,
String? group,
}) {
throw UnimplementedError();
}
@override
Future<List<bool>> pauseTaskList(Iterable<Task> tasksToPause) {
throw UnimplementedError();
}
@override
Future<(String, String)> configureItem((String, dynamic) configItem) {
throw UnimplementedError();
}
@override
Future<Duration> getTaskTimeout() {
throw UnimplementedError();
}
@override
Future<bool> openFile(Task? task, String? filePath, String? mimeType) {
throw UnimplementedError();
}
@override
Future<bool> pause(Task task) {
throw UnimplementedError();
}
@override
platformConfig({globalConfig, androidConfig, iOSConfig, desktopConfig}) {
throw UnimplementedError();
}
@override
Future<Map<String, String>> popUndeliveredData(Undelivered dataType) {
throw UnimplementedError();
}
@override
Future<void> setForceFailPostOnBackgroundChannel(bool value) {
throw UnimplementedError();
}
@override
Future<String> testSuggestedFilename(
DownloadTask task,
String contentDisposition,
) {
throw UnimplementedError();
}
@override
Future<List<bool>> enqueueAll(Iterable<Task> tasks) {
throw UnimplementedError();
}
}
/// The default maximum number of redirects to follow for HTTP requests
const defaultMaxRedirects = DesktopDownloader.defaultMaxRedirects;
@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import '../models.dart';
import '../task.dart';
import '../transfer.dart';
/// An adaptive action button for a [Transfer].
///
/// Dynamically toggles between Pause, Resume, Retry, and Complete actions
/// based on the transfer's current [TaskStatus].
class TransferButton extends StatelessWidget {
/// The [Transfer] to control.
final Transfer transfer;
/// Icon size in logical pixels (defaults to `24.0`).
final double iconSize;
/// Padding around the icon button.
final EdgeInsetsGeometry padding;
/// Color override for the icon.
final Color? color;
/// Optional callback invoked when the user cancels the transfer.
///
/// If omitted, defaults to calling [Transfer.cancel].
final VoidCallback? onCancel;
/// Creates a reactive [TransferButton].
const TransferButton({
super.key,
required this.transfer,
this.iconSize = 24.0,
this.padding = const EdgeInsets.all(8.0),
this.color,
this.onCancel,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ValueListenableBuilder<TaskStatus>(
valueListenable: transfer.statusNotifier,
builder: (context, status, _) {
final canPause =
transfer.task is DownloadTask && transfer.task.allowPause;
return switch (status) {
.running || .enqueued =>
canPause
? IconButton(
icon: Icon(Icons.pause_circle_outline, size: iconSize),
color: color ?? theme.colorScheme.primary,
padding: padding,
tooltip: 'Pause',
onPressed: () => transfer.pause(),
)
: IconButton(
icon: Icon(Icons.cancel_outlined, size: iconSize),
color: color ?? theme.colorScheme.error,
padding: padding,
tooltip: 'Cancel',
onPressed: onCancel ?? () => transfer.cancel(),
),
.paused || .waitingToRetry => IconButton(
icon: Icon(Icons.play_circle_outline, size: iconSize),
color: color ?? Colors.orange,
padding: padding,
tooltip: 'Resume',
onPressed: () => transfer.resume(),
),
.failed || .notFound => IconButton(
icon: Icon(Icons.refresh, size: iconSize),
color: color ?? theme.colorScheme.error,
padding: padding,
tooltip: 'Retry',
onPressed: () => transfer.resume(),
),
.complete => Icon(
Icons.check_circle,
size: iconSize,
color: color ?? Colors.green,
),
.canceled => IconButton(
icon: Icon(Icons.replay, size: iconSize),
color: color ?? theme.colorScheme.outline,
padding: padding,
tooltip: 'Restart',
onPressed: () => transfer.resume(),
),
};
},
);
}
}
/// Alias for [TransferButton] for backward compatibility
typedef FileTransferButton = TransferButton;
@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import '../task.dart';
import '../transfer.dart';
import 'transfer_button.dart';
import 'transfer_progress_bar.dart';
/// A plug-and-play [ListTile] displaying a [Transfer] with
/// filename, progress bar, transfer speed, and action controls.
class TransferListTile extends StatelessWidget {
/// The [Transfer] to display and interact with.
final Transfer transfer;
/// Optional custom leading widget. If null, a circular icon based on task type is rendered.
final Widget? leading;
/// Optional custom trailing widget. If null, a [TransferButton] is rendered.
final Widget? trailing;
/// Optional callback invoked when the tile is tapped.
final VoidCallback? onTap;
/// Whether to show live network speed in the progress subtitle. Defaults to `true`.
final bool showSpeed;
/// Whether to show percentage text in the progress subtitle. Defaults to `true`.
final bool showPercentage;
/// Creates a [TransferListTile].
const TransferListTile({
super.key,
required this.transfer,
this.leading,
this.trailing,
this.onTap,
this.showSpeed = true,
this.showPercentage = true,
});
@override
Widget build(BuildContext context) {
final title = transfer.task.displayName.isNotEmpty
? transfer.task.displayName
: transfer.task.filename;
return ListTile(
leading: leading ?? _defaultLeading(context),
title: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 6.0),
child: TransferProgressBar(
transfer: transfer,
showSpeed: showSpeed,
showPercentage: showPercentage,
),
),
trailing: trailing ?? TransferButton(transfer: transfer),
onTap: onTap,
);
}
Widget _defaultLeading(BuildContext context) {
final theme = Theme.of(context);
final isUpload = transfer.task is UploadTask;
return CircleAvatar(
backgroundColor: theme.colorScheme.primaryContainer,
foregroundColor: theme.colorScheme.onPrimaryContainer,
child: Icon(isUpload ? Icons.upload_file : Icons.download, size: 20),
);
}
}
/// Alias for [TransferListTile] for backward compatibility
typedef FileTransferListTile = TransferListTile;
@@ -0,0 +1,190 @@
import 'package:flutter/material.dart';
import '../models.dart';
import '../transfer.dart';
/// A reactive progress bar widget bound to a [Transfer].
///
/// Automatically updates progress, percentage, transfer speed, and status
/// without requiring parent widget rebuilds.
class TransferProgressBar extends StatelessWidget {
/// The [Transfer] to observe.
final Transfer transfer;
/// Whether to display percentage text (e.g. `'45%'`). Defaults to `true`.
final bool showPercentage;
/// Whether to display the text status badge (e.g. `'Transferring'`, `'Waiting for Wi-Fi'`). Defaults to `true`.
final bool showStatusText;
/// Whether to display live network transfer speed (e.g. `'1.2 MB/s'`). Defaults to `true`.
final bool showSpeed;
/// Custom color override for the progress bar. If null, a color based on [TaskStatus] is used.
final Color? progressColor;
/// Background color of the progress bar track.
final Color? backgroundColor;
/// Custom [TextStyle] for the status, speed, and percentage labels.
final TextStyle? textStyle;
/// Height of the progress bar in logical pixels. Defaults to `6.0`.
final double height;
/// Creates a reactive [TransferProgressBar].
const TransferProgressBar({
super.key,
required this.transfer,
this.showPercentage = true,
this.showStatusText = true,
this.showSpeed = true,
this.progressColor,
this.backgroundColor,
this.textStyle,
this.height = 6.0,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectiveTextStyle =
textStyle ?? theme.textTheme.bodySmall ?? const TextStyle(fontSize: 12);
return ValueListenableBuilder<TaskStatus>(
valueListenable: transfer.statusNotifier,
builder: (context, status, _) =>
ValueListenableBuilder<TransferHoldReason>(
valueListenable: transfer.holdReasonNotifier,
builder: (context, holdReason, _) => ValueListenableBuilder<double?>(
valueListenable: transfer.progressNotifier,
builder: (context, progress, _) => ValueListenableBuilder<double>(
valueListenable: transfer.networkSpeedNotifier,
builder: (context, speed, _) {
final effectiveColor = _colorForStatus(
status,
holdReason,
theme,
);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(height / 2),
child: SizedBox(
height: height,
child: LinearProgressIndicator(
value: status == TaskStatus.complete
? 1.0
: (progress != null && progress >= 0.0
? progress
: null),
color: effectiveColor,
backgroundColor:
backgroundColor ??
theme.colorScheme.surfaceContainerHighest,
),
),
),
if (showPercentage || showStatusText || showSpeed) ...[
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (showStatusText)
Text(
_statusLabel(status, holdReason),
style: effectiveTextStyle.copyWith(
color: effectiveColor,
fontWeight: FontWeight.w500,
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (showSpeed &&
speed > 0 &&
status == TaskStatus.running) ...[
Text(
_formatSpeed(speed),
style: effectiveTextStyle.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 8),
],
if (showPercentage) ...[
if (status == TaskStatus.complete)
Text(
'100%',
style: effectiveTextStyle.copyWith(
fontWeight: FontWeight.bold,
),
)
else if (progress != null && progress >= 0.0)
Text(
'${(progress * 100).toStringAsFixed(0)}%',
style: effectiveTextStyle.copyWith(
fontWeight: FontWeight.bold,
),
),
],
],
),
],
),
],
],
);
},
),
),
),
);
}
Color _colorForStatus(
TaskStatus status,
TransferHoldReason holdReason,
ThemeData theme,
) {
if (progressColor != null) return progressColor!;
if (holdReason != TransferHoldReason.none) {
return Colors.orange;
}
return switch (status) {
.complete => Colors.green,
.failed || .notFound => theme.colorScheme.error,
.paused || .waitingToRetry => Colors.orange,
.running || .enqueued => theme.colorScheme.primary,
.canceled => theme.colorScheme.outline,
};
}
String _statusLabel(TaskStatus status, TransferHoldReason holdReason) =>
switch (holdReason) {
.waitingForWiFi => 'Waiting for Wi-Fi',
.offline => 'Waiting for network',
.none => switch (status) {
.enqueued => 'Enqueued',
.running => 'Transferring',
.complete => 'Complete',
.paused => 'Paused',
.waitingToRetry => 'Waiting to retry',
.failed => 'Failed',
.notFound => 'Not found',
.canceled => 'Canceled',
},
};
String _formatSpeed(double mbPerSec) => switch (mbPerSec) {
<= 0 => '',
>= 1.0 => '${mbPerSec.toStringAsFixed(1)} MB/s',
_ => '${(mbPerSec * 1000).round()} kB/s',
};
}
/// Alias for [TransferProgressBar] for backward compatibility
typedef FileTransferProgressBar = TransferProgressBar;