1557 lines
63 KiB
Dart
1557 lines
63 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:math';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:logging/logging.dart';
|
|
|
|
import 'base_downloader.dart';
|
|
import 'database.dart';
|
|
import 'localstore/localstore.dart';
|
|
import 'models.dart';
|
|
import 'permissions.dart';
|
|
import 'persistent_storage.dart';
|
|
import 'queue/task_queue.dart';
|
|
import 'task.dart';
|
|
import 'transfers.dart';
|
|
import 'uri/uri_utils.dart';
|
|
import 'web_downloader.dart'
|
|
if (dart.library.io) 'desktop/desktop_downloader.dart';
|
|
|
|
/// Provides access to all functions of the plugin in a single place.
|
|
interface class FileDownloader {
|
|
static FileDownloader? _singleton;
|
|
static final Map<String, FileDownloader> _scopedInstances = {};
|
|
|
|
/// If no group is specified the default group name will be used
|
|
static const defaultGroup = 'default';
|
|
|
|
/// Special group name for tasks that download a chunk, as part of a
|
|
/// [ParallelDownloadTask]
|
|
static String get chunkGroup => BaseDownloader.chunkGroup;
|
|
|
|
/// The namespace for this scoped downloader instance, or null for the root default instance
|
|
final String? namespace;
|
|
|
|
/// Whether this downloader instance is scoped to a specific namespace
|
|
bool get hasNamespace => namespace != null && namespace!.isNotEmpty;
|
|
|
|
/// Database where tracked tasks are stored.
|
|
///
|
|
/// Activate tracking by calling [trackTasks], and access the records in the
|
|
/// database via this [database] object.
|
|
late final Database database;
|
|
|
|
/// Transfer management and reactive collections service
|
|
late final Transfers transfers;
|
|
|
|
/// Permissions authorization interface
|
|
///
|
|
/// Use [Permissions.status] to get the authorization status for a permission
|
|
/// Use [Permissions.request] to request a permission
|
|
/// Use [Permissions.shouldShowRationale] to determine if an educational
|
|
/// rationale for this permission should be shown
|
|
Permissions get permissions => _downloader.permissionsService;
|
|
|
|
/// Platform-specific implementation of the downloader itself
|
|
late final BaseDownloader _downloader;
|
|
|
|
/// Accesses utilities for working with URIs
|
|
UriUtils? _uri;
|
|
|
|
/// Do not use: for testing only
|
|
@visibleForTesting
|
|
BaseDownloader get downloaderForTesting => _downloader;
|
|
|
|
factory FileDownloader({
|
|
PersistentStorage? persistentStorage,
|
|
String? namespace,
|
|
}) {
|
|
if (namespace != null && namespace.isNotEmpty) {
|
|
return FileDownloader.scoped(
|
|
namespace,
|
|
persistentStorage: persistentStorage,
|
|
);
|
|
}
|
|
assert(
|
|
_singleton == null || persistentStorage == null,
|
|
'You can only supply a persistentStorage on the very first call to '
|
|
'FileDownloader()',
|
|
);
|
|
_singleton ??= FileDownloader._internal(
|
|
persistentStorage ?? LocalStorePersistentStorage(),
|
|
null,
|
|
);
|
|
return _singleton!;
|
|
}
|
|
|
|
/// Returns a cached, scoped [FileDownloader] instance for [namespace].
|
|
///
|
|
/// Scoped instances share the underlying downloader engine and database,
|
|
/// but isolate group naming, callback dispatch, task queries, and cancellation
|
|
/// to the given namespace.
|
|
factory FileDownloader.scoped(
|
|
String namespace, {
|
|
PersistentStorage? persistentStorage,
|
|
}) {
|
|
if (namespace.isEmpty) {
|
|
return FileDownloader(persistentStorage: persistentStorage);
|
|
}
|
|
return _scopedInstances.putIfAbsent(namespace, () {
|
|
final root = FileDownloader(persistentStorage: persistentStorage);
|
|
return FileDownloader._scoped(root.database, root._downloader, namespace);
|
|
});
|
|
}
|
|
|
|
FileDownloader._internal(
|
|
PersistentStorage persistentStorage,
|
|
this.namespace,
|
|
) {
|
|
database = Database(persistentStorage);
|
|
_downloader = BaseDownloader.instance(persistentStorage, database);
|
|
transfers = Transfers(this, _downloader);
|
|
}
|
|
|
|
FileDownloader._scoped(this.database, this._downloader, this.namespace) {
|
|
transfers = Transfers(this, _downloader);
|
|
}
|
|
|
|
/// True when initialization is complete and downloader ready for use
|
|
Future<bool> get ready => _downloader.ready;
|
|
|
|
/// True when the device currently has an active network connection
|
|
bool get isConnected => _downloader.isConnected;
|
|
|
|
@visibleForTesting
|
|
set isConnected(bool value) => _downloader.isConnected = value;
|
|
|
|
/// True when the active network connection is Wi-Fi
|
|
bool get isWiFi => _downloader.isWiFi;
|
|
|
|
@visibleForTesting
|
|
set isWiFi(bool value) => _downloader.isWiFi = value;
|
|
|
|
/// Stream of [TaskUpdate] updates for downloads that do
|
|
/// not have a registered callback.
|
|
///
|
|
/// **Important**: This is a single-subscription stream. It can only be listened to
|
|
/// once. If you are developing a package or plugin, you should **not** listen
|
|
/// to this stream, as doing so will prevent the main application from listening to it.
|
|
/// Instead, use [registerCallbacks] with a custom group to monitor tasks specific
|
|
/// to your package.
|
|
Stream<TaskUpdate> get updates => _downloader.updates.stream;
|
|
|
|
/// Accesses utilities for working with URIs. URIs make working with file pickers and
|
|
/// shared storage easier, as they abstract permissions and are coherent
|
|
/// across platforms.
|
|
UriUtils get uri {
|
|
_uri ??= UriUtils.withDownloader(_downloader);
|
|
return _uri!;
|
|
}
|
|
|
|
/// Configures the downloader
|
|
///
|
|
/// Configuration is either a single configItem or a list of configItems.
|
|
/// Each configItem is a (String, dynamic) where the String is the config
|
|
/// type and 'dynamic' can be any appropriate parameter, including another Record.
|
|
/// [globalConfig] is routed to every platform, whereas the platform specific
|
|
/// ones only get routed to that platform, after the global configs have
|
|
/// completed.
|
|
/// If a config type appears more than once, they will all be executed in order,
|
|
/// with [globalConfig] executed before the platform-specific config.
|
|
///
|
|
/// Returns a list of (String, String) which is the config type and a response
|
|
/// which is empty if OK, 'not implemented' if the item could not be recognized and
|
|
/// processed, or may contain other error/warning information
|
|
///
|
|
/// Please see [CONFIG.md](https://github.com/781flyingdutchman/background_downloader/blob/main/CONFIG.md)
|
|
/// for more information
|
|
Future<List<(String, String)>> configure({
|
|
dynamic globalConfig,
|
|
dynamic androidConfig,
|
|
dynamic iOSConfig,
|
|
dynamic desktopConfig,
|
|
}) => _downloader.configure(
|
|
globalConfig: globalConfig,
|
|
androidConfig: androidConfig,
|
|
iOSConfig: iOSConfig,
|
|
desktopConfig: desktopConfig,
|
|
);
|
|
|
|
/// Converts a group name to its namespaced equivalent.
|
|
String namespacedGroup(String? group) {
|
|
final cleanGroup = (group == null || group.isEmpty)
|
|
? FileDownloader.defaultGroup
|
|
: group;
|
|
if (!hasNamespace) return cleanGroup;
|
|
if (cleanGroup == FileDownloader.defaultGroup) return '$namespace.default';
|
|
if (cleanGroup.startsWith('$namespace.')) return cleanGroup;
|
|
return '$namespace.$cleanGroup';
|
|
}
|
|
|
|
/// Converts a namespaced group name back to the original un-namespaced group name.
|
|
String originalGroup(String group) {
|
|
if (!hasNamespace) return group;
|
|
if (group == '$namespace.default') return FileDownloader.defaultGroup;
|
|
if (group.startsWith('$namespace.')) {
|
|
return group.substring(namespace!.length + 1);
|
|
}
|
|
return group;
|
|
}
|
|
|
|
/// Returns a copy of [task] with its group scoped to this namespace.
|
|
Task withNamespacedGroup(Task task) {
|
|
if (!hasNamespace) return task;
|
|
return task.copyWith(group: namespacedGroup(task.group));
|
|
}
|
|
|
|
/// Returns a copy of [task] with the namespace prefix removed from its group.
|
|
Task withoutNamespacedGroup(Task task) {
|
|
if (!hasNamespace) return task;
|
|
return task.copyWith(group: originalGroup(task.group));
|
|
}
|
|
|
|
/// Register status or progress callbacks to monitor download progress, and
|
|
/// [TaskNotificationTapCallback] to respond to user tapping a notification.
|
|
///
|
|
/// Status callbacks are called only when the state changes, while
|
|
/// progress callbacks are called to inform of intermediate progress.
|
|
///
|
|
/// Note that callbacks will be called based on a task's [updates]
|
|
/// property, which defaults to status change callbacks only. To also get
|
|
/// progress updates make sure to register a [TaskProgressCallback] and
|
|
/// set the task's [updates] property to [Updates.progress] or
|
|
/// [Updates.statusAndProgress].
|
|
///
|
|
/// For notification callbacks, make sure your AndroidManifest includes
|
|
/// android:launchMode="singleTask" to ensure proper behavior when a
|
|
/// notification is tapped.
|
|
///
|
|
/// Different callbacks can be set for different groups, and the group
|
|
/// can be passed on with the [Task] to ensure the
|
|
/// appropriate callbacks are called for that group.
|
|
/// For the `taskNotificationTapCallback` callback, the `defaultGroup` callback
|
|
/// is used when calling 'convenience' functions like `FileDownloader().download`
|
|
///
|
|
/// **Important for package developers**: Tasks belonging to a group that has
|
|
/// registered callbacks will **not** emit updates to the [updates] stream.
|
|
/// This makes [registerCallbacks] the preferred way for packages to monitor
|
|
/// their own tasks, as it avoids interfering with the main application's
|
|
/// single-subscription listener on the [updates] stream.
|
|
static final _registeredStatusCallbacks = <String, TaskStatusCallback>{};
|
|
static final _registeredProgressCallbacks = <String, TaskProgressCallback>{};
|
|
static final _registeredNotificationTapCallbacks =
|
|
<String, TaskNotificationTapCallback>{};
|
|
|
|
///
|
|
/// The call returns the [FileDownloader] to make chaining easier
|
|
FileDownloader registerCallbacks({
|
|
String group = defaultGroup,
|
|
TaskStatusCallback? taskStatusCallback,
|
|
TaskProgressCallback? taskProgressCallback,
|
|
TaskNotificationTapCallback? taskNotificationTapCallback,
|
|
}) {
|
|
assert(
|
|
taskStatusCallback != null ||
|
|
taskProgressCallback != null ||
|
|
taskNotificationTapCallback != null,
|
|
'Must provide at least one callback',
|
|
);
|
|
final nGroup = namespacedGroup(group);
|
|
if (taskStatusCallback != null) {
|
|
if (hasNamespace) {
|
|
_registeredStatusCallbacks[nGroup] = taskStatusCallback;
|
|
_downloader.groupStatusCallbacks[nGroup] = (update) {
|
|
taskStatusCallback(
|
|
TaskStatusUpdate(
|
|
withoutNamespacedGroup(update.task),
|
|
update.status,
|
|
update.exception,
|
|
update.responseBody,
|
|
update.responseHeaders,
|
|
update.responseStatusCode,
|
|
update.mimeType,
|
|
update.charSet,
|
|
),
|
|
);
|
|
};
|
|
} else {
|
|
_downloader.groupStatusCallbacks[nGroup] = taskStatusCallback;
|
|
}
|
|
}
|
|
if (taskProgressCallback != null) {
|
|
if (hasNamespace) {
|
|
_registeredProgressCallbacks[nGroup] = taskProgressCallback;
|
|
_downloader.groupProgressCallbacks[nGroup] = (update) {
|
|
taskProgressCallback(
|
|
TaskProgressUpdate(
|
|
withoutNamespacedGroup(update.task),
|
|
update.progress,
|
|
update.expectedFileSize,
|
|
update.networkSpeed,
|
|
update.timeRemaining,
|
|
),
|
|
);
|
|
};
|
|
} else {
|
|
_downloader.groupProgressCallbacks[nGroup] = taskProgressCallback;
|
|
}
|
|
}
|
|
if (taskNotificationTapCallback != null) {
|
|
if (hasNamespace) {
|
|
_registeredNotificationTapCallbacks[nGroup] =
|
|
taskNotificationTapCallback;
|
|
_downloader.groupNotificationTapCallbacks[nGroup] =
|
|
(task, notificationType) {
|
|
taskNotificationTapCallback(
|
|
withoutNamespacedGroup(task),
|
|
notificationType,
|
|
);
|
|
};
|
|
} else {
|
|
_downloader.groupNotificationTapCallbacks[nGroup] =
|
|
taskNotificationTapCallback;
|
|
}
|
|
}
|
|
return this;
|
|
}
|
|
|
|
/// Unregister a previously registered [TaskStatusCallback], [TaskProgressCallback]
|
|
/// or [TaskNotificationTapCallback].
|
|
///
|
|
/// [group] defaults to the [FileDownloader.defaultGroup]
|
|
/// If [callback] is null, all callbacks for the [group] are unregistered
|
|
FileDownloader unregisterCallbacks({
|
|
String group = defaultGroup,
|
|
Function? callback,
|
|
}) {
|
|
final nGroup = namespacedGroup(group);
|
|
if (callback != null) {
|
|
// remove specific callback
|
|
if (_downloader.groupStatusCallbacks[nGroup] == callback ||
|
|
_registeredStatusCallbacks[nGroup] == callback) {
|
|
_downloader.groupStatusCallbacks.remove(nGroup);
|
|
_registeredStatusCallbacks.remove(nGroup);
|
|
}
|
|
if (_downloader.groupProgressCallbacks[nGroup] == callback ||
|
|
_registeredProgressCallbacks[nGroup] == callback) {
|
|
_downloader.groupProgressCallbacks.remove(nGroup);
|
|
_registeredProgressCallbacks.remove(nGroup);
|
|
}
|
|
if (_downloader.groupNotificationTapCallbacks[nGroup] == callback ||
|
|
_registeredNotificationTapCallbacks[nGroup] == callback) {
|
|
_downloader.groupNotificationTapCallbacks.remove(nGroup);
|
|
_registeredNotificationTapCallbacks.remove(nGroup);
|
|
}
|
|
} else {
|
|
// remove all callbacks related to group
|
|
_downloader.groupStatusCallbacks.remove(nGroup);
|
|
_downloader.groupProgressCallbacks.remove(nGroup);
|
|
_downloader.groupNotificationTapCallbacks.remove(nGroup);
|
|
_registeredStatusCallbacks.remove(nGroup);
|
|
_registeredProgressCallbacks.remove(nGroup);
|
|
_registeredNotificationTapCallbacks.remove(nGroup);
|
|
}
|
|
return this;
|
|
}
|
|
|
|
/// Adds the [taskQueue] to this downloader
|
|
///
|
|
/// Every [TaskQueue] will receive [TaskQueue.taskFinished] for
|
|
/// every task that has reached a final state
|
|
void addTaskQueue(TaskQueue taskQueue) =>
|
|
_downloader.taskQueues.add(taskQueue);
|
|
|
|
/// Removes [taskQueue] and return true if successful
|
|
bool removeTaskQueue(TaskQueue taskQueue) =>
|
|
_downloader.taskQueues.remove(taskQueue);
|
|
|
|
/// List of connected [TaskQueue]s
|
|
List<TaskQueue> get taskQueues => _downloader.taskQueues;
|
|
|
|
/// Enqueue a new [Task]
|
|
///
|
|
/// Returns true if successfully enqueued. A new task will also generate
|
|
/// a [TaskStatus.enqueued] update to the registered callback,
|
|
/// if requested by its [updates] property
|
|
///
|
|
/// Use [enqueue] instead of the convenience functions (like
|
|
/// [download] and [upload]) if:
|
|
/// - your download/upload is likely to take long and may require
|
|
/// running in the background
|
|
/// - you want to monitor tasks centrally, via a listener
|
|
/// - you want more detailed progress information
|
|
/// (e.g. file size, network speed, time remaining)
|
|
Future<bool> enqueue(Task task) =>
|
|
_downloader.enqueue(withNamespacedGroup(task));
|
|
|
|
/// Enqueues a list of tasks and returns a list of booleans indicating whether
|
|
/// each task was successfully enqueued
|
|
///
|
|
/// See [enqueue] for details
|
|
Future<List<bool>> enqueueAll(Iterable<Task> tasks) =>
|
|
_downloader.enqueueAll(tasks.map(withNamespacedGroup));
|
|
|
|
/// Download a file and return the final [TaskStatusUpdate]
|
|
///
|
|
/// Different from [enqueue], this method returns a [Future] that completes
|
|
/// when the file has been downloaded, or an error has occurred.
|
|
/// While it uses the same download mechanism as [enqueue],
|
|
/// and will execute the download also when
|
|
/// the app moves to the background, it is meant for downloads that are
|
|
/// awaited while the app is in the foreground.
|
|
///
|
|
/// Optional callbacks for status and progress updates may be
|
|
/// added. These function only take a [TaskStatus] or [double] argument as
|
|
/// the task they refer to is expected to be captured in the closure for
|
|
/// this call.
|
|
/// For example `Downloader.download(task, onStatus: (status) =>`
|
|
/// `print('Status for ${task.taskId} is $status);`
|
|
///
|
|
/// An optional callback [onElapsedTime] will be called at regular intervals
|
|
/// (defined by [elapsedTimeInterval], which defaults to 5 seconds) with a
|
|
/// single argument that is the elapsed time since the call to [download].
|
|
/// This can be used to trigger UI warnings (e.g. 'this is taking rather long')
|
|
/// or to cancel the task if it does not complete within a desired time.
|
|
/// For performance reasons the [elapsedTimeInterval] should not be set to
|
|
/// a value less than one second.
|
|
/// The [onElapsedTime] callback should not be used to indicate progress. For
|
|
/// that, use the [onProgress] callback.
|
|
///
|
|
/// Use [enqueue] instead of [download] if:
|
|
/// - your download/upload is likely to take long and may require
|
|
/// running in the background
|
|
/// - you want to monitor tasks centrally, via a listener
|
|
/// - you want more detailed progress information
|
|
/// (e.g. file size, network speed, time remaining)
|
|
Future<TaskStatusUpdate> download(
|
|
DownloadTask task, {
|
|
void Function(TaskStatus)? onStatus,
|
|
void Function(double)? onProgress,
|
|
void Function(Duration)? onElapsedTime,
|
|
Duration? elapsedTimeInterval,
|
|
}) => _downloader.enqueueAndAwait(
|
|
withNamespacedGroup(task) as DownloadTask,
|
|
onStatus: onStatus,
|
|
onProgress: onProgress,
|
|
onElapsedTime: onElapsedTime,
|
|
elapsedTimeInterval: elapsedTimeInterval,
|
|
);
|
|
|
|
/// Upload a file and return the final [TaskStatusUpdate]
|
|
///
|
|
/// Different from [enqueue], this method returns a [Future] that completes
|
|
/// when the file has been uploaded, or an error has occurred.
|
|
/// While it uses the same upload mechanism as [enqueue],
|
|
/// and will execute the upload also when
|
|
/// the app moves to the background, it is meant for uploads that are
|
|
/// awaited while the app is in the foreground.
|
|
///
|
|
/// Optional callbacks for status and progress updates may be
|
|
/// added. These function only take a [TaskStatus] or [double] argument as
|
|
/// the task they refer to is expected to be captured in the closure for
|
|
/// this call.
|
|
/// For example `Downloader.upload(task, onStatus: (status) =>`
|
|
/// `print('Status for ${task.taskId} is $status);`
|
|
///
|
|
/// An optional callback [onElapsedTime] will be called at regular intervals
|
|
/// (defined by [elapsedTimeInterval], which defaults to 5 seconds) with a
|
|
/// single argument that is the elapsed time since the call to [upload].
|
|
/// This can be used to trigger UI warnings (e.g. 'this is taking rather long')
|
|
/// or to cancel the task if it does not complete within a desired time.
|
|
/// For performance reasons the [elapsedTimeInterval] should not be set to
|
|
/// a value less than one second.
|
|
/// The [onElapsedTime] callback should not be used to indicate progress. For
|
|
/// that, use the [onProgress] callback.
|
|
///
|
|
/// Note that the task's [group] is ignored and will be replaced with an
|
|
/// internal group name 'await' to track status
|
|
///
|
|
/// Use [enqueue] instead of [upload] if:
|
|
/// - your download/upload is likely to take long and may require
|
|
/// running in the background
|
|
/// - you want to monitor tasks centrally, via a listener
|
|
/// - you want more detailed progress information
|
|
/// (e.g. file size, network speed, time remaining)
|
|
Future<TaskStatusUpdate> upload(
|
|
UploadTask task, {
|
|
void Function(TaskStatus)? onStatus,
|
|
void Function(double)? onProgress,
|
|
void Function(Duration)? onElapsedTime,
|
|
Duration? elapsedTimeInterval,
|
|
}) => _downloader.enqueueAndAwait(
|
|
withNamespacedGroup(task) as UploadTask,
|
|
onStatus: onStatus,
|
|
onProgress: onProgress,
|
|
onElapsedTime: onElapsedTime,
|
|
elapsedTimeInterval: elapsedTimeInterval,
|
|
);
|
|
|
|
/// Transmit data in the [DataTask] and receive the response
|
|
///
|
|
/// Different from [enqueue], this method returns a [Future] that completes
|
|
/// when the [DataTask] has completed, or an error has occurred.
|
|
/// While it uses the same mechanism as [enqueue],
|
|
/// and will execute the task also when
|
|
/// the app moves to the background, it is meant for data tasks that are
|
|
/// awaited while the app is in the foreground.
|
|
///
|
|
/// [onStatus] is an optional callback for status updates
|
|
///
|
|
/// An optional callback [onElapsedTime] will be called at regular intervals
|
|
/// (defined by [elapsedTimeInterval], which defaults to 5 seconds) with a
|
|
/// single argument that is the elapsed time since the call to [transmit].
|
|
/// This can be used to trigger UI warnings (e.g. 'this is taking rather long')
|
|
/// For performance reasons the [elapsedTimeInterval] should not be set to
|
|
/// a value less than one second.
|
|
Future<TaskStatusUpdate> transmit(
|
|
DataTask task, {
|
|
void Function(TaskStatus)? onStatus,
|
|
void Function(Duration)? onElapsedTime,
|
|
Duration? elapsedTimeInterval,
|
|
}) => _downloader.enqueueAndAwait(
|
|
withNamespacedGroup(task) as DataTask,
|
|
onStatus: onStatus,
|
|
onElapsedTime: onElapsedTime,
|
|
elapsedTimeInterval: elapsedTimeInterval,
|
|
);
|
|
|
|
/// Enqueues a list of files to download and returns when all downloads
|
|
/// have finished (successfully or otherwise). The returned value is a
|
|
/// [Batch] object that contains the original [tasks], the
|
|
/// [Batch.results] and convenience getters to filter successful and failed results.
|
|
///
|
|
/// If an optional [batchProgressCallback] function is provided, it will be
|
|
/// called upon completion (successfully or otherwise) of each task in the
|
|
/// batch, with two parameters: the number of succeeded and the number of
|
|
/// failed tasks. The callback can be used, for instance, to show a progress
|
|
/// indicator for the batch, where
|
|
/// double percent_complete = (succeeded + failed) / tasks.length
|
|
///
|
|
/// To also monitor status and/or progress for each task in the batch, provide
|
|
/// a [taskStatusCallback] and/or [taskProgressCallback], which will be used
|
|
/// for each task in the batch.
|
|
///
|
|
/// An optional callback [onElapsedTime] will be called at regular intervals
|
|
/// (defined by [elapsedTimeInterval], which defaults to 5 seconds) with a
|
|
/// single argument that is the elapsed time since the call to [downloadBatch].
|
|
/// This can be used to trigger UI warnings (e.g. 'this is taking rather long')
|
|
/// or to cancel the task if it does not complete within a desired time.
|
|
/// For performance reasons the [elapsedTimeInterval] should not be set to
|
|
/// a value less than one second.
|
|
/// The [onElapsedTime] callback should not be used to indicate progress.
|
|
///
|
|
/// [tasks] cannot be an empty list
|
|
Future<Batch> downloadBatch(
|
|
List<DownloadTask> tasks, {
|
|
BatchProgressCallback? batchProgressCallback,
|
|
TaskStatusCallback? taskStatusCallback,
|
|
TaskProgressCallback? taskProgressCallback,
|
|
void Function(Duration)? onElapsedTime,
|
|
Duration? elapsedTimeInterval,
|
|
}) => _downloader.enqueueAndAwaitBatch(
|
|
tasks.map((t) => withNamespacedGroup(t) as DownloadTask).toList(),
|
|
batchProgressCallback: batchProgressCallback,
|
|
taskStatusCallback: taskStatusCallback != null
|
|
? (update) => taskStatusCallback(
|
|
TaskStatusUpdate(
|
|
withoutNamespacedGroup(update.task),
|
|
update.status,
|
|
update.exception,
|
|
update.responseBody,
|
|
update.responseHeaders,
|
|
update.responseStatusCode,
|
|
update.mimeType,
|
|
update.charSet,
|
|
),
|
|
)
|
|
: null,
|
|
taskProgressCallback: taskProgressCallback != null
|
|
? (update) => taskProgressCallback(
|
|
TaskProgressUpdate(
|
|
withoutNamespacedGroup(update.task),
|
|
update.progress,
|
|
update.expectedFileSize,
|
|
update.networkSpeed,
|
|
update.timeRemaining,
|
|
),
|
|
)
|
|
: null,
|
|
onElapsedTime: onElapsedTime,
|
|
elapsedTimeInterval: elapsedTimeInterval,
|
|
);
|
|
|
|
/// Enqueues a list of files to upload and returns when all uploads
|
|
/// have finished (successfully or otherwise). The returned value is a
|
|
/// [Batch] object that contains the original [tasks], the
|
|
/// [Batch.results] and convenience getters to filter successful and failed results.
|
|
///
|
|
/// If an optional [batchProgressCallback] function is provided, it will be
|
|
/// called upon completion (successfully or otherwise) of each task in the
|
|
/// batch, with two parameters: the number of succeeded and the number of
|
|
/// failed tasks. The callback can be used, for instance, to show a progress
|
|
/// indicator for the batch, where
|
|
/// double percent_complete = (succeeded + failed) / tasks.length
|
|
///
|
|
/// To also monitor status and/or progress for each task in the batch, provide
|
|
/// a [taskStatusCallback] and/or [taskProgressCallback], which will be used
|
|
/// for each task in the batch.
|
|
///
|
|
/// An optional callback [onElapsedTime] will be called at regular intervals
|
|
/// (defined by [elapsedTimeInterval], which defaults to 5 seconds) with a
|
|
/// single argument that is the elapsed time since the call to [uploadBatch].
|
|
/// This can be used to trigger UI warnings (e.g. 'this is taking rather long')
|
|
/// or to cancel the task if it does not complete within a desired time.
|
|
/// For performance reasons the [elapsedTimeInterval] should not be set to
|
|
/// a value less than one second.
|
|
/// The [onElapsedTime] callback should not be used to indicate progress.
|
|
///
|
|
/// [tasks] cannot be an empty list
|
|
Future<Batch> uploadBatch(
|
|
List<UploadTask> tasks, {
|
|
BatchProgressCallback? batchProgressCallback,
|
|
TaskStatusCallback? taskStatusCallback,
|
|
TaskProgressCallback? taskProgressCallback,
|
|
void Function(Duration)? onElapsedTime,
|
|
Duration? elapsedTimeInterval,
|
|
}) => _downloader.enqueueAndAwaitBatch(
|
|
tasks.map((t) => withNamespacedGroup(t) as UploadTask).toList(),
|
|
batchProgressCallback: batchProgressCallback,
|
|
taskStatusCallback: taskStatusCallback != null
|
|
? (update) => taskStatusCallback(
|
|
TaskStatusUpdate(
|
|
withoutNamespacedGroup(update.task),
|
|
update.status,
|
|
update.exception,
|
|
update.responseBody,
|
|
update.responseHeaders,
|
|
update.responseStatusCode,
|
|
update.mimeType,
|
|
update.charSet,
|
|
),
|
|
)
|
|
: null,
|
|
taskProgressCallback: taskProgressCallback != null
|
|
? (update) => taskProgressCallback(
|
|
TaskProgressUpdate(
|
|
withoutNamespacedGroup(update.task),
|
|
update.progress,
|
|
update.expectedFileSize,
|
|
update.networkSpeed,
|
|
update.timeRemaining,
|
|
),
|
|
)
|
|
: null,
|
|
onElapsedTime: onElapsedTime,
|
|
elapsedTimeInterval: elapsedTimeInterval,
|
|
);
|
|
|
|
/// Resets the downloader by cancelling all ongoing tasks within
|
|
/// the provided [group] (or within this namespace).
|
|
///
|
|
/// Returns the number of tasks cancelled. Every canceled task will emit a
|
|
/// [TaskStatus.canceled] update to the registered callback, if requested.
|
|
///
|
|
/// This method acts on a [group] of tasks. If omitted, the [defaultGroup]
|
|
/// is used, which is the group used when you [enqueue] a task.
|
|
Future<int> reset({String group = defaultGroup}) async {
|
|
if (hasNamespace) {
|
|
return _downloader.reset(namespacedGroup(group));
|
|
}
|
|
return _downloader.reset(group);
|
|
}
|
|
|
|
/// Returns a list of taskIds of all tasks currently active in this [group]
|
|
///
|
|
/// Active means enqueued or running, and if [includeTasksWaitingToRetry] is
|
|
/// true also tasks that are waiting to be retried.
|
|
///
|
|
/// This method acts on a [group] of tasks. If omitted, the [defaultGroup]
|
|
/// is used, which is the group used when you [enqueue] a task.
|
|
/// To get all tasks regardless of group, set [allGroups] to true as the
|
|
/// only parameter.
|
|
Future<List<String>> allTaskIds({
|
|
String group = defaultGroup,
|
|
bool includeTasksWaitingToRetry = true,
|
|
allGroups = false,
|
|
}) async => (await allTasks(
|
|
group: group,
|
|
includeTasksWaitingToRetry: includeTasksWaitingToRetry,
|
|
allGroups: allGroups,
|
|
)).map((task) => task.taskId).toList();
|
|
|
|
/// Returns a list of all tasks currently active in this [group]
|
|
///
|
|
/// Active means enqueued or running, and if [includeTasksWaitingToRetry] is
|
|
/// true also tasks that are waiting to be retried.
|
|
///
|
|
/// This method acts on a [group] of tasks. If omitted, the [defaultGroup]
|
|
/// is used, which is the group used when you [enqueue] a task.
|
|
/// To get all tasks regardless of group, set [allGroups] to true as the
|
|
/// only parameter.
|
|
Future<List<Task>> allTasks({
|
|
String group = defaultGroup,
|
|
bool includeTasksWaitingToRetry = true,
|
|
bool allGroups = false,
|
|
}) async {
|
|
if (hasNamespace) {
|
|
if (allGroups) {
|
|
final rawTasks = await _downloader.allTasks(
|
|
defaultGroup,
|
|
includeTasksWaitingToRetry,
|
|
true,
|
|
);
|
|
return rawTasks
|
|
.where((t) => t.group.startsWith('$namespace.'))
|
|
.map(withoutNamespacedGroup)
|
|
.toList();
|
|
}
|
|
final rawTasks = await _downloader.allTasks(
|
|
namespacedGroup(group),
|
|
includeTasksWaitingToRetry,
|
|
false,
|
|
);
|
|
return rawTasks.map(withoutNamespacedGroup).toList();
|
|
}
|
|
return _downloader.allTasks(group, includeTasksWaitingToRetry, allGroups);
|
|
}
|
|
|
|
/// Returns true if tasks in this [group] are finished
|
|
///
|
|
/// Finished means "not active", i.e. no tasks are enqueued or running,
|
|
/// and if [includeTasksWaitingToRetry] is true (the default), no tasks are
|
|
/// waiting to be retried.
|
|
/// Finished does not mean that all tasks completed successfully.
|
|
///
|
|
/// This method acts on a [group] of tasks. If omitted, the [defaultGroup]
|
|
/// is used, which is the group used when you [enqueue] a task.
|
|
///
|
|
/// If an [ignoreTaskId] is provided, it will be excluded from the test. This
|
|
/// allows you to test for [tasksFinished] within the status update callback
|
|
/// for a task that just finished. In that situation, that task may still
|
|
/// be returned by the platform as 'active', but you already know it is not.
|
|
/// Calling [tasksFinished] while passing that just-finished task will ensure
|
|
/// a proper test in that situation.
|
|
Future<bool> tasksFinished({
|
|
String group = defaultGroup,
|
|
bool includeTasksWaitingToRetry = true,
|
|
String? ignoreTaskId,
|
|
}) async {
|
|
final tasksInProgress = await allTasks(
|
|
group: group,
|
|
includeTasksWaitingToRetry: includeTasksWaitingToRetry,
|
|
);
|
|
if (ignoreTaskId != null) {
|
|
tasksInProgress.removeWhere((task) => task.taskId == ignoreTaskId);
|
|
}
|
|
return tasksInProgress.isEmpty;
|
|
}
|
|
|
|
/// Cancel all tasks matching the taskIds in the list
|
|
///
|
|
/// Every canceled task wil emit a [TaskStatus.canceled] update to
|
|
/// the registered callback, if requested
|
|
Future<bool> cancelTasksWithIds(Iterable<String> taskIds) =>
|
|
_downloader.cancelTasksWithIds(taskIds);
|
|
|
|
/// Cancel this task
|
|
///
|
|
/// The task will emit a [TaskStatus.canceled] update to
|
|
/// the registered callback, if requested
|
|
Future<bool> cancelTaskWithId(String taskId) => cancelTasksWithIds([taskId]);
|
|
|
|
/// Cancel this task
|
|
///
|
|
/// The task will emit a [TaskStatus.canceled] update to
|
|
/// the registered callback, if requested
|
|
Future<bool> cancel(Task task) => cancelTasksWithIds([task.taskId]);
|
|
|
|
/// Cancels all tasks, or those in [tasks], or all tasks in group [group]
|
|
///
|
|
/// Returns true if all cancellations were successful
|
|
Future<bool> cancelAll({Iterable<Task>? tasks, String? group}) async {
|
|
if (hasNamespace) {
|
|
if (tasks != null) {
|
|
return _downloader.cancelAll(tasks: tasks.map(withNamespacedGroup));
|
|
}
|
|
final nTasks = await allTasks(
|
|
group: group ?? defaultGroup,
|
|
allGroups: group == null,
|
|
);
|
|
return _downloader.cancelAll(tasks: nTasks.map(withNamespacedGroup));
|
|
}
|
|
return _downloader.cancelAll(tasks: tasks, group: group);
|
|
}
|
|
|
|
/// Return [Task] for the given [taskId], or null
|
|
/// if not found.
|
|
///
|
|
/// Only running tasks are guaranteed to be returned, but returning a task
|
|
/// does not guarantee that the task is still running. To keep track of
|
|
/// the status of tasks, use a [TaskStatusCallback]
|
|
Future<Task?> taskForId(String taskId) => _downloader.taskForId(taskId);
|
|
|
|
/// Convenience start method for using the database. Must be called AFTER
|
|
/// registering update callbacks or listener.
|
|
///
|
|
/// Calls in order:
|
|
/// [trackTasks] to start database tracking, with [markDownloadedComplete] to
|
|
/// ensure fully downloaded tasks are marked as complete in the database
|
|
/// [resumeFromBackground] to fetch status and progress updates that may have
|
|
/// happened while the app was suspended
|
|
/// [rescheduleKilledTasks] (after a 5 second delay) to ensure tasks that were
|
|
/// killed by the user are rescheduled
|
|
///
|
|
/// [doTrackTasks] and [doRescheduleKilledTasks] can be set to false to skip
|
|
/// that step. [resumeFromBackground] is always called.
|
|
///
|
|
/// If [autoCleanDatabase] is true, [Database.cleanUp] is called after
|
|
/// initialization to purge old or excess records and prevent the database
|
|
/// from growing indefinitely. While [autoCleanDatabase] defaults to `false`
|
|
/// to maintain backwards compatibility for existing code, calling
|
|
/// `FileDownloader().start(autoCleanDatabase: true)` is typically recommended.
|
|
Future<void> start({
|
|
bool doTrackTasks = true,
|
|
bool markDownloadedComplete = true,
|
|
bool doRescheduleKilledTasks = true,
|
|
bool autoCleanDatabase = false,
|
|
}) async {
|
|
if (doTrackTasks) {
|
|
await trackTasks(markDownloadedComplete: markDownloadedComplete);
|
|
await transfers.rehydrateFromDatabase();
|
|
if (doRescheduleKilledTasks) {
|
|
Timer(const Duration(seconds: 5), () => rescheduleKilledTasks());
|
|
}
|
|
}
|
|
await resumeFromBackground();
|
|
if (autoCleanDatabase) {
|
|
database.cleanUp();
|
|
}
|
|
}
|
|
|
|
/// Activate tracking for tasks in this [group]
|
|
///
|
|
/// All subsequent tasks in this group will be recorded in persistent storage.
|
|
/// Use the [FileDownloader.database] to get or remove [TaskRecord] objects,
|
|
/// which contain a [Task], its [TaskStatus] and a [double] for progress.
|
|
///
|
|
/// If [markDownloadedComplete] is true (default) then all tasks in the
|
|
/// database that are marked as not yet [TaskStatus.complete] will be set to
|
|
/// [TaskStatus.complete] if the target file for that task exists.
|
|
/// They will also emit [TaskStatus.complete] and [progressComplete] to
|
|
/// their registered listener or callback.
|
|
/// This is a convenient way to capture downloads that have completed while
|
|
/// the app was suspended: on app startup, immediately register your
|
|
/// listener or callbacks, and call [trackTasks] for each group.
|
|
///
|
|
/// Returns the [FileDownloader] for easy chaining
|
|
Future<FileDownloader> trackTasksInGroup(
|
|
String group, {
|
|
bool markDownloadedComplete = true,
|
|
}) async {
|
|
await _downloader.trackTasks(
|
|
namespacedGroup(group),
|
|
markDownloadedComplete,
|
|
);
|
|
return this;
|
|
}
|
|
|
|
/// Activate tracking for all tasks
|
|
///
|
|
/// All subsequent tasks will be recorded in persistent storage.
|
|
/// Use the [FileDownloader.database] to get or remove [TaskRecord] objects,
|
|
/// which contain a [Task], its [TaskStatus] and a [double] for progress.
|
|
///
|
|
/// If [markDownloadedComplete] is true (default) then all tasks in the
|
|
/// database that are marked as not yet [TaskStatus.complete] will be set to
|
|
/// [TaskStatus.complete] if the target file for that task exists.
|
|
/// They will also emit [TaskStatus.complete] and [progressComplete] to
|
|
/// their registered listener or callback.
|
|
/// This is a convenient way to capture downloads that have completed while
|
|
/// the app was suspended: on app startup, immediately register your
|
|
/// listener or callbacks, and call [trackTasks].
|
|
///
|
|
/// Returns the [FileDownloader] for easy chaining
|
|
Future<FileDownloader> trackTasks({
|
|
bool markDownloadedComplete = true,
|
|
}) async {
|
|
if (hasNamespace) {
|
|
await _downloader.trackTasks(
|
|
namespacedGroup(null),
|
|
markDownloadedComplete,
|
|
);
|
|
} else {
|
|
await _downloader.trackTasks(null, markDownloadedComplete);
|
|
}
|
|
return this;
|
|
}
|
|
|
|
/// Wakes up the FileDownloader from possible background state, triggering
|
|
/// a stream of updates that may have been processed while in the background,
|
|
/// and have not yet reached the callbacks or listener
|
|
///
|
|
/// Calling this method multiple times has no effect.
|
|
Future<void> resumeFromBackground() =>
|
|
_downloader.retrieveLocallyStoredData();
|
|
|
|
/// Reschedules tasks that are present in the database but missing from
|
|
/// the native task queue. Typically called on app start, 5s after establishing
|
|
/// the updates listener, calling [trackTasks] or [trackTasksInGroup] and
|
|
/// calling [resumeFromBackground].
|
|
///
|
|
/// This function retrieves all tasks from the database that are in enqueued
|
|
/// or running states and compares these tasks with the
|
|
/// list of tasks currently present in the native task queue, and all tasks
|
|
/// that are in waitingToRetry state that are not actually waiting to retry
|
|
/// in the downloader
|
|
///
|
|
/// For each task found only in the database (or waitingToRetry yet not
|
|
/// actually waiting), the function:
|
|
/// 1. Deletes the corresponding record from the database.
|
|
/// 2. Enqueues the task back into the native task queue.
|
|
///
|
|
/// Finally, the function returns two lists of Tasks in a record. The first
|
|
/// item is the list of successfully re-enqueued tasks, the second item
|
|
/// is the list of tasks that failed to enqueue.
|
|
///
|
|
/// Throws assertion error if you are not currently tracking tasks, as that
|
|
/// makes this function a no-op that always returns empty lists.
|
|
Future<(List<Task>, List<Task>)> rescheduleKilledTasks() async {
|
|
assert(
|
|
_downloader.isTrackingTasks,
|
|
'rescheduleKilledTasks should only be called if you are tracking tasks. '
|
|
'Did you call trackTasks or trackTasksInGroup?',
|
|
);
|
|
await _downloader.flushDatabaseUpdates();
|
|
final missingTasks = <Task>{};
|
|
final databaseTasks = await database.allRecords();
|
|
// find missing enqueued/running tasks
|
|
final enqueuedOrRunningDatabaseTasks = databaseTasks
|
|
.where(
|
|
(record) => const [
|
|
TaskStatus.enqueued,
|
|
TaskStatus.running,
|
|
].contains(record.status),
|
|
)
|
|
.map((record) => record.task)
|
|
.toSet();
|
|
final nativeTasks = Set<Task>.from(await allTasks(allGroups: true));
|
|
missingTasks.addAll(enqueuedOrRunningDatabaseTasks.difference(nativeTasks));
|
|
// find missing tasks waiting to retry
|
|
missingTasks.addAll(
|
|
databaseTasks
|
|
.where(
|
|
(record) =>
|
|
record.status == TaskStatus.waitingToRetry &&
|
|
!_downloader.tasksWaitingToRetry.contains(record.task),
|
|
)
|
|
.map((record) => record.task),
|
|
);
|
|
final successfullyEnqueued = <Task>[];
|
|
final failedToEnqueue = <Task>[];
|
|
var startTime = DateTime.now();
|
|
for (final task in missingTasks) {
|
|
await database.deleteRecordWithId(task.taskId);
|
|
if (await enqueue(task)) {
|
|
successfullyEnqueued.add(task);
|
|
} else {
|
|
failedToEnqueue.add(task);
|
|
}
|
|
if (DateTime.now().difference(startTime).inMilliseconds > 10) {
|
|
await Future.delayed(const Duration(milliseconds: 50));
|
|
startTime = DateTime.now();
|
|
}
|
|
}
|
|
return (successfullyEnqueued, failedToEnqueue);
|
|
}
|
|
|
|
/// Returns true if task can be resumed on pause
|
|
///
|
|
/// This future only completes once the task is running and has received
|
|
/// information from the server to determine whether resume is possible, or
|
|
/// if the task fails and resume is possible
|
|
Future<bool> taskCanResume(Task task) =>
|
|
_downloader.taskCanResume(withNamespacedGroup(task));
|
|
|
|
/// Pause the task
|
|
///
|
|
/// Returns true if the pause was attempted successfully. Test the task's
|
|
/// status to see if it was executed successfully [TaskStatus.paused] or if
|
|
/// it failed after all [TaskStatus.failed]
|
|
///
|
|
/// If the [Task.allowPause] field is set to false (default) or if this is
|
|
/// a POST request, this method returns false immediately.
|
|
Future<bool> pause(DownloadTask task) async {
|
|
if (task.allowPause && task.post == null) {
|
|
return _downloader.pause(withNamespacedGroup(task) as DownloadTask);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Pauses all tasks, or those in [tasks], or all tasks in group [group]
|
|
///
|
|
/// Returns list of tasks that were paused
|
|
Future<List<DownloadTask>> pauseAll({
|
|
Iterable<DownloadTask>? tasks,
|
|
String? group,
|
|
}) {
|
|
final nGroup = group != null
|
|
? namespacedGroup(group)
|
|
: (hasNamespace ? namespacedGroup(null) : null);
|
|
final nTasks = tasks?.map((t) => withNamespacedGroup(t) as DownloadTask);
|
|
for (final taskQueue in _downloader.taskQueues) {
|
|
taskQueue.pauseAll(tasks: nTasks, group: nGroup);
|
|
}
|
|
return _downloader.pauseAll(tasks: nTasks, group: nGroup);
|
|
}
|
|
|
|
/// Resume the task
|
|
///
|
|
/// If no resume data is available for this task, the call to [resume]
|
|
/// will return false and the task is not resumed.
|
|
/// If resume data is available, the call to [resume] will return true,
|
|
/// but this does not guarantee that resuming is actually possible, just that
|
|
/// the task is now enqueued for resume.
|
|
/// If the task is able to resume, it will, otherwise it will restart the
|
|
/// task from scratch, or fail.
|
|
Future<bool> resume(DownloadTask task) =>
|
|
_downloader.resume(withNamespacedGroup(task) as DownloadTask);
|
|
|
|
/// Resume all paused tasks, or those in [tasks], or paused tasks in
|
|
/// group [group]
|
|
///
|
|
/// Calls to resume will be spaced out over time by [interval], defaults to 50ms
|
|
Future<List<Task>> resumeAll({
|
|
Iterable<DownloadTask>? tasks,
|
|
String? group,
|
|
Duration interval = const Duration(milliseconds: 50),
|
|
}) async {
|
|
final nGroup = group != null
|
|
? namespacedGroup(group)
|
|
: (hasNamespace ? namespacedGroup(null) : null);
|
|
final nTasks = tasks?.map((t) => withNamespacedGroup(t) as DownloadTask);
|
|
final results = <Task>[];
|
|
final tasksToResume = switch ((nTasks, nGroup)) {
|
|
(final Iterable<DownloadTask> tasks, null) => tasks,
|
|
(null, final String group) =>
|
|
(await _downloader.getPausedTasks()).whereType<DownloadTask>().where(
|
|
(task) => task.group == group,
|
|
),
|
|
(null, null) =>
|
|
(await _downloader.getPausedTasks()).whereType<DownloadTask>(),
|
|
_ => throw AssertionError(
|
|
"Either 'tasks' or 'group' must be provided, or neither, but not both.",
|
|
),
|
|
};
|
|
for (final task in tasksToResume) {
|
|
if (await resume(task)) {
|
|
results.add(withoutNamespacedGroup(task));
|
|
}
|
|
await Future.delayed(interval);
|
|
}
|
|
for (final taskQueue in _downloader.taskQueues) {
|
|
taskQueue.resumeAll(tasks: nTasks, group: nGroup);
|
|
}
|
|
return results;
|
|
}
|
|
|
|
/// Set WiFi requirement globally, based on [requirement].
|
|
///
|
|
/// Affects future tasks and reschedules enqueued, inactive tasks
|
|
/// (both downloads and uploads) with the new setting.
|
|
/// Reschedules running download tasks if [rescheduleRunningTasks] is true,
|
|
/// otherwise leaves those running with their prior setting.
|
|
/// Restarts running upload tasks if [alsoRestartUploads] is true (which
|
|
/// requires [rescheduleRunningTasks] to be true as well).
|
|
///
|
|
/// Note that on Android (API 28+), requiring WiFi enforces a Wi-Fi network constraint
|
|
/// (`TRANSPORT_WIFI`), preventing tasks from running over cellular connections even if unmetered.
|
|
/// On older Android versions, it enforces an unmetered network constraint (`NETWORK_TYPE_UNMETERED`).
|
|
/// On iOS, requiring WiFi disables cellular access (`allowsCellularAccess = false`), allowing
|
|
/// execution over any Wi-Fi or Ethernet connection.
|
|
Future<bool> requireWiFi(
|
|
RequireWiFi requirement, {
|
|
rescheduleRunningTasks = true,
|
|
alsoRestartUploads = false,
|
|
}) {
|
|
assert(
|
|
!(alsoRestartUploads && !rescheduleRunningTasks),
|
|
'alsoRestartUploads can only be set if rescheduleRunningTasks is true',
|
|
);
|
|
return _downloader.requireWiFi(
|
|
requirement,
|
|
rescheduleRunningTasks,
|
|
alsoRestartUploads,
|
|
);
|
|
}
|
|
|
|
/// Returns the current global setting for requiring WiFi
|
|
Future<RequireWiFi> getRequireWiFiSetting() =>
|
|
_downloader.getRequireWiFiSetting();
|
|
|
|
/// Configure notification for a single task
|
|
///
|
|
/// The configuration 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 [groupNotificationId].
|
|
/// Use {numFinished} and {numTotal} tokens in the [TaskNotification.title]
|
|
/// and [TaskNotification.body] to substitute. Task-specific substitutions
|
|
/// such as {filename} are not valid when using [groupNotificationId].
|
|
/// The [groupNotificationId] 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
|
|
/// [groupNotificationId] fails, the [error] notification is shown.
|
|
/// The first character of the [groupNotificationId] cannot be '*'.
|
|
///
|
|
/// The [TaskNotification] is the actual notification shown for a [Task], and
|
|
/// [TaskNotification.body] and [TaskNotification.title] may contain special strings to substitute display values:
|
|
/// {filename} to insert the [Task.filename]
|
|
/// {metaData} to insert the [Task.metaData]
|
|
/// {displayName} to insert the [Task.displayName]
|
|
/// {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
|
|
/// {numFinished} to insert the number of finished tasks in a groupNotification
|
|
/// {numFailed} to insert the number of failed tasks in a groupNotification
|
|
/// {numTotal} to insert the number of tasks in a groupNotification
|
|
///
|
|
/// Actual appearance of notification is dependent on the platform, e.g.
|
|
/// on iOS {progress} is not available and ignored (except for groupNotifications)
|
|
///
|
|
/// Returns the [FileDownloader] for easy chaining
|
|
FileDownloader configureNotificationForTask(
|
|
Task task, {
|
|
TaskNotification? running,
|
|
TaskNotification? complete,
|
|
TaskNotification? error,
|
|
TaskNotification? paused,
|
|
TaskNotification? canceled,
|
|
bool progressBar = false,
|
|
bool tapOpensFile = false,
|
|
String groupNotificationId = '',
|
|
}) {
|
|
_addOrUpdateTaskNotificationConfig(
|
|
TaskNotificationConfig(
|
|
taskOrGroup: withNamespacedGroup(task),
|
|
running: running,
|
|
complete: complete,
|
|
error: error,
|
|
paused: paused,
|
|
canceled: canceled,
|
|
progressBar: progressBar,
|
|
tapOpensFile: tapOpensFile,
|
|
groupNotificationId: groupNotificationId,
|
|
),
|
|
);
|
|
return this;
|
|
}
|
|
|
|
/// Configure notification for a group of tasks
|
|
///
|
|
/// The configuration 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 [groupNotificationId].
|
|
/// Use {numFinished} and {numTotal} tokens in the [TaskNotification.title]
|
|
/// and [TaskNotification.body] to substitute. Task-specific substitutions
|
|
/// such as {filename} are not valid when using [groupNotificationId].
|
|
/// The [groupNotificationId] 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
|
|
/// [groupNotificationId] fails, the [error] notification is shown.
|
|
/// The first character of the [groupNotificationId] cannot be '*'.
|
|
///
|
|
/// The [TaskNotification] is the actual notification shown for a [Task], and
|
|
/// [TaskNotification.body] and [TaskNotification.title] may contain special strings to substitute display values:
|
|
/// {filename} to insert the [Task.filename]
|
|
/// {metaData} to insert the [Task.metaData]
|
|
/// {displayName} to insert the [Task.displayName]
|
|
/// {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
|
|
/// {numFinished} to insert the number of finished tasks in a groupNotification
|
|
/// {numFailed} to insert the number of failed tasks in a groupNotification
|
|
/// {numTotal} to insert the number of tasks in a groupNotification
|
|
///
|
|
/// Actual appearance of notification is dependent on the platform, e.g.
|
|
/// on iOS {progress} is not available and ignored (except for groupNotifications)
|
|
///
|
|
/// Returns the [FileDownloader] for easy chaining
|
|
FileDownloader configureNotificationForGroup(
|
|
String group, {
|
|
TaskNotification? running,
|
|
TaskNotification? complete,
|
|
TaskNotification? error,
|
|
TaskNotification? paused,
|
|
TaskNotification? canceled,
|
|
bool progressBar = false,
|
|
bool tapOpensFile = false,
|
|
String groupNotificationId = '',
|
|
}) {
|
|
_addOrUpdateTaskNotificationConfig(
|
|
TaskNotificationConfig(
|
|
taskOrGroup: namespacedGroup(group),
|
|
running: running,
|
|
complete: complete,
|
|
error: error,
|
|
paused: paused,
|
|
canceled: canceled,
|
|
progressBar: progressBar,
|
|
tapOpensFile: tapOpensFile,
|
|
groupNotificationId: groupNotificationId,
|
|
),
|
|
);
|
|
return this;
|
|
}
|
|
|
|
/// Configure default task notification
|
|
///
|
|
/// The configuration 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 [groupNotificationId].
|
|
/// Use {numFinished} and {numTotal} tokens in the [TaskNotification.title]
|
|
/// and [TaskNotification.body] to substitute. Task-specific substitutions
|
|
/// such as {filename} are not valid when using [groupNotificationId].
|
|
/// The [groupNotificationId] 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
|
|
/// [groupNotificationId] fails, the [error] notification is shown.
|
|
/// The first character of the [groupNotificationId] cannot be '*'.
|
|
///
|
|
/// The [TaskNotification] is the actual notification shown for a [Task], and
|
|
/// [TaskNotification.body] and [TaskNotification.title] may contain special
|
|
/// strings to substitute display values:
|
|
/// {filename} to insert the [Task.filename]
|
|
/// {metaData} to insert the [Task.metaData]
|
|
/// {displayName} to insert the [Task.displayName]
|
|
/// {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
|
|
/// {numFinished} to insert the number of finished tasks in a groupNotification
|
|
/// {numFailed} to insert the number of failed tasks in a groupNotification
|
|
/// {numTotal} to insert the number of tasks in a groupNotification
|
|
///
|
|
/// Actual appearance of notification is dependent on the platform, e.g.
|
|
/// on iOS {progress} is not available and ignored (except for groupNotifications)
|
|
///
|
|
/// Returns the [FileDownloader] for easy chaining
|
|
FileDownloader configureNotification({
|
|
TaskNotification? running,
|
|
TaskNotification? complete,
|
|
TaskNotification? error,
|
|
TaskNotification? paused,
|
|
TaskNotification? canceled,
|
|
bool progressBar = false,
|
|
bool tapOpensFile = false,
|
|
String groupNotificationId = '',
|
|
}) {
|
|
_addOrUpdateTaskNotificationConfig(
|
|
TaskNotificationConfig(
|
|
taskOrGroup: null,
|
|
running: running,
|
|
complete: complete,
|
|
error: error,
|
|
paused: paused,
|
|
canceled: canceled,
|
|
progressBar: progressBar,
|
|
tapOpensFile: tapOpensFile,
|
|
groupNotificationId: groupNotificationId,
|
|
),
|
|
);
|
|
return this;
|
|
}
|
|
|
|
/// Helper to add or update a task notification config
|
|
void _addOrUpdateTaskNotificationConfig(
|
|
TaskNotificationConfig taskNotificationConfig,
|
|
) {
|
|
_downloader.notificationConfigs.remove(taskNotificationConfig);
|
|
_downloader.notificationConfigs.add(taskNotificationConfig);
|
|
}
|
|
|
|
/// Returns the [TaskNotificationConfig] configured for [task], if any
|
|
TaskNotificationConfig? notificationConfigForTask(Task task) =>
|
|
_downloader.notificationConfigForTask(task);
|
|
|
|
/// Perform a server request for this [request]
|
|
///
|
|
/// A server request returns an [http.Response] object that includes
|
|
/// the [http.Response.body] as String, the [http.Response.bodyBytes] as [Uint8List] and the [json]
|
|
/// representation if available.
|
|
/// It also contains the [http.Response.statusCode] and [http.Response.reasonPhrase] that may indicate
|
|
/// an error, and several other fields that may be useful.
|
|
/// A local error (e.g. a SocketException) will yield [http.Response.statusCode] 499, with
|
|
/// details in the [http.Response.reasonPhrase]
|
|
///
|
|
/// The request will abide by the [Request.retries] set on the [request], and set
|
|
/// [Request.headers] included in the [request]. Redirects are followed automatically
|
|
/// (up to 10 redirects).
|
|
///
|
|
/// The [http.Client] object used for this request is the [DesktopDownloader.httpClient] field of
|
|
/// the downloader. If not set, the default [http.Client] will be used.
|
|
/// The request is executed on an Isolate, to ensure minimal interference
|
|
/// with the main Isolate
|
|
Future<http.Response> request(Request request) => compute(_doRequest, (
|
|
request,
|
|
DesktopDownloader.requestTimeout,
|
|
DesktopDownloader.proxy,
|
|
DesktopDownloader.bypassTLSCertificateValidation,
|
|
DesktopDownloader.mtlsConfigs,
|
|
));
|
|
|
|
/// 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
|
|
/// [Task.filePath] extension
|
|
///
|
|
/// Returns the path to 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<String?> moveToSharedStorage(
|
|
DownloadTask task,
|
|
SharedStorage destination, {
|
|
String directory = '',
|
|
String? mimeType,
|
|
}) async => moveFileToSharedStorage(
|
|
await task.filePath(),
|
|
destination,
|
|
directory: directory,
|
|
mimeType: mimeType,
|
|
);
|
|
|
|
/// Move the file represented by [filePath] 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
|
|
/// [filePath] extension
|
|
///
|
|
/// Returns the path to 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<String?> moveFileToSharedStorage(
|
|
String filePath,
|
|
SharedStorage destination, {
|
|
String directory = '',
|
|
String? mimeType,
|
|
}) async => _downloader.moveToSharedStorage(
|
|
filePath,
|
|
destination,
|
|
directory,
|
|
mimeType,
|
|
);
|
|
|
|
/// Returns the filePath to the file represented by [filePath] 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<String?> pathInSharedStorage(
|
|
String filePath,
|
|
SharedStorage destination, {
|
|
String directory = '',
|
|
}) async => _downloader.pathInSharedStorage(filePath, destination, directory);
|
|
|
|
/// Open the file represented by [task] or [filePath] using the application
|
|
/// available on the platform.
|
|
///
|
|
/// [mimeType] may override the mimetype derived from the file extension,
|
|
/// though implementation depends on the platform and may not always work.
|
|
///
|
|
/// Returns true if an application was launched successfully
|
|
Future<bool> openFile({Task? task, String? filePath, String? mimeType}) {
|
|
assert(task != null || filePath != null, 'Task or filePath must be set');
|
|
assert(
|
|
!(task != null && filePath != null),
|
|
'Either task or filePath must be set, not both',
|
|
);
|
|
return _downloader.openFile(task, filePath, mimeType);
|
|
}
|
|
|
|
/// Return the platform version as a String
|
|
///
|
|
/// On Android this is the API integer, e.g. "33"
|
|
/// On iOS this is the iOS version, e.g. "16.1"
|
|
/// On desktop this is a description of the OS version, not parsable
|
|
Future<String> platformVersion() => _downloader.platformVersion();
|
|
|
|
/// Closes the [updates] stream and re-initializes the [StreamController]
|
|
/// such that the stream can be listened to again
|
|
Future<void> resetUpdates() => _downloader.resetUpdatesStreamController();
|
|
|
|
/// Destroy the [FileDownloader]. Subsequent use requires initialization
|
|
void destroy() {
|
|
transfers.clear();
|
|
transfers.dispose();
|
|
_registeredStatusCallbacks.clear();
|
|
_registeredProgressCallbacks.clear();
|
|
_registeredNotificationTapCallbacks.clear();
|
|
_scopedInstances.clear();
|
|
_downloader.destroy();
|
|
try {
|
|
Localstore.instance.clearCache();
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
/// Performs the actual server request, with retries
|
|
///
|
|
/// This function is run on an Isolate to ensure performance on the main
|
|
/// Isolate is not affected
|
|
Future<http.Response> _doRequest(
|
|
(Request, Duration?, Map<String, dynamic>, bool, List<MTLSConfig>) params,
|
|
) async {
|
|
final (
|
|
request,
|
|
requestTimeout,
|
|
proxy,
|
|
bypassTLSCertificateValidation,
|
|
mtlsConfigs,
|
|
) = params;
|
|
Logger.root.level = Level.ALL;
|
|
Logger.root.onRecord.listen((LogRecord rec) {
|
|
if (kDebugMode) {
|
|
print('${rec.loggerName}>${rec.level.name}: ${rec.time}: ${rec.message}');
|
|
}
|
|
});
|
|
final log = Logger('FileDownloader.request');
|
|
DesktopDownloader.setHttpClient(
|
|
requestTimeout,
|
|
proxy,
|
|
bypassTLSCertificateValidation,
|
|
mtlsConfigs,
|
|
);
|
|
final client = DesktopDownloader.httpClientForUrl(request.url);
|
|
var response = http.Response(
|
|
'',
|
|
499,
|
|
reasonPhrase: 'Not attempted',
|
|
); // dummy to start with
|
|
while (request.retriesRemaining >= 0) {
|
|
try {
|
|
response = await switch (request.httpRequestMethod) {
|
|
'GET' => client.get(Uri.parse(request.url), headers: request.headers),
|
|
'POST' => client.post(
|
|
Uri.parse(request.url),
|
|
headers: request.headers,
|
|
body: request.postBody,
|
|
),
|
|
'HEAD' => client.head(Uri.parse(request.url), headers: request.headers),
|
|
'PUT' => client.put(
|
|
Uri.parse(request.url),
|
|
headers: request.headers,
|
|
body: request.postBody,
|
|
),
|
|
'DELETE' => client.delete(
|
|
Uri.parse(request.url),
|
|
headers: request.headers,
|
|
body: request.postBody,
|
|
),
|
|
'PATCH' => client.patch(
|
|
Uri.parse(request.url),
|
|
headers: request.headers,
|
|
body: request.postBody,
|
|
),
|
|
_ => Future.value(response),
|
|
};
|
|
if ([
|
|
200,
|
|
201,
|
|
202,
|
|
203,
|
|
204,
|
|
205,
|
|
206,
|
|
404,
|
|
].contains(response.statusCode)) {
|
|
return response;
|
|
}
|
|
} catch (e) {
|
|
log.warning(e);
|
|
response = http.Response('', 499, reasonPhrase: e.toString());
|
|
}
|
|
// error, retry if allowed
|
|
request.decreaseRetriesRemaining();
|
|
if (request.retriesRemaining < 0) {
|
|
return response; // final response with error
|
|
}
|
|
final waitTime = Duration(
|
|
seconds: pow(2, (request.retries - request.retriesRemaining)).toInt(),
|
|
);
|
|
await Future.delayed(waitTime);
|
|
}
|
|
throw ArgumentError('Request to ${request.url} had no retries remaining');
|
|
}
|