[verified] Publish ReelBox source under GPLv3
This commit is contained in:
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
@@ -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;
|
||||
}
|
||||
@@ -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))));
|
||||
+454
@@ -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);
|
||||
}
|
||||
}
|
||||
+29
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+1723
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
+190
@@ -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;
|
||||
Reference in new issue
Block a user