This commit is contained in:
kodjomoustapha 2023-11-13 18:23:14 +01:00
parent 770741f024
commit 9b770027db
45 changed files with 10257 additions and 270 deletions

View file

@ -319,7 +319,7 @@ class _AnimeStreamPageState extends riv.ConsumerState<AnimeStreamPage> {
);
}
void _videoSettingDraggableMenu(BuildContext context) async {
void _videoSettingDraggableMenu(BuildContext context) async {
final l10n = l10nLocalizations(context)!;
_player.pause();
await DraggableMenu.open(
@ -549,49 +549,49 @@ class _AnimeStreamPageState extends riv.ConsumerState<AnimeStreamPage> {
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_seekToWidget(),
Row(
children: [
if (!isFullScreen)
IconButton(
padding: const EdgeInsets.all(5),
onPressed: () => _videoSettingDraggableMenu(context),
icon: const Icon(
Icons.video_settings_outlined,
size: 25,
color: Colors.white,
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_seekToWidget(),
Row(
children: [
if (!isFullScreen)
IconButton(
padding: const EdgeInsets.all(5),
onPressed: () => _videoSettingDraggableMenu(context),
icon: const Icon(
Icons.video_settings,
size: 25,
color: Colors.white,
),
),
),
TextButton(
child: ValueListenableBuilder<double>(
valueListenable: _playbackSpeed,
builder: (context, value, child) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
TextButton(
child: ValueListenableBuilder<double>(
valueListenable: _playbackSpeed,
builder: (context, value, child) {
return Text(
"${value}x",
style: const TextStyle(color: Colors.white),
),
);
);
},
),
onPressed: () {
_togglePlaybackSpeed();
}),
if (!isFullScreen)
IconButton(
icon: const Icon(Icons.fit_screen_sharp,
size: 25, color: Colors.white),
onPressed: () async {
_changeFitLabel(ref);
},
),
onPressed: () {
_togglePlaybackSpeed();
}),
if (!isFullScreen)
IconButton(
icon: const Icon(Icons.fit_screen_sharp,
size: 25, color: Colors.white),
onPressed: () async {
_changeFitLabel(ref);
},
),
],
),
],
],
),
],
),
),
const Padding(
padding: EdgeInsets.only(bottom: 20),
@ -631,90 +631,87 @@ class _AnimeStreamPageState extends riv.ConsumerState<AnimeStreamPage> {
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Row(
children: [
_seekToWidget(),
],
Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: Row(
children: [
_seekToWidget(),
],
),
),
const SizedBox(height: 20, child: MaterialDesktopSeekBar()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (_isDesktop)
Row(
children: [
if (hasPrevEpisode)
IconButton(
onPressed: () {
pushReplacementMangaReaderView(
context: context,
chapter: _streamController.getPrevEpisode());
},
icon: const Icon(
Icons.skip_previous_outlined,
size: 25,
color: Colors.white,
),
),
const MaterialDesktopPlayOrPauseButton(iconSize: 25),
if (hasNextEpisode)
IconButton(
onPressed: () {
pushReplacementMangaReaderView(
context: context,
chapter: _streamController.getNextEpisode(),
);
},
icon: const Icon(Icons.skip_next_outlined,
size: 25, color: Colors.white),
),
const MaterialDesktopVolumeButton(iconSize: 25),
const MaterialDesktopPositionIndicator()
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (_isDesktop)
Row(
children: [
if (!isFullScreen)
if (hasPrevEpisode)
IconButton(
onPressed: () => _videoSettingDraggableMenu(context),
onPressed: () {
pushReplacementMangaReaderView(
context: context,
chapter: _streamController.getPrevEpisode());
},
icon: const Icon(
Icons.video_settings_outlined,
Icons.skip_previous,
size: 25,
color: Colors.white,
),
),
TextButton(
child: ValueListenableBuilder<double>(
valueListenable: _playbackSpeed,
builder: (context, value, child) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
"${value}x",
style: const TextStyle(color: Colors.white),
),
);
},
),
onPressed: () {
_togglePlaybackSpeed();
}),
if (!isFullScreen)
const MaterialDesktopPlayOrPauseButton(iconSize: 25),
if (hasNextEpisode)
IconButton(
icon: const Icon(Icons.fit_screen_outlined,
size: 25, color: Colors.white),
onPressed: () async {
_changeFitLabel(ref);
onPressed: () {
pushReplacementMangaReaderView(
context: context,
chapter: _streamController.getNextEpisode(),
);
},
icon: const Icon(Icons.skip_next,
size: 25, color: Colors.white),
),
if (_isDesktop)
const MaterialDesktopFullscreenButton(iconSize: 25)
const MaterialDesktopVolumeButton(iconSize: 25),
const MaterialDesktopPositionIndicator()
],
),
],
),
Row(
children: [
if (!isFullScreen)
IconButton(
onPressed: () => _videoSettingDraggableMenu(context),
icon: const Icon(
Icons.video_settings,
size: 25,
color: Colors.white,
),
),
TextButton(
child: ValueListenableBuilder<double>(
valueListenable: _playbackSpeed,
builder: (context, value, child) {
return Text(
"${value}x",
style: const TextStyle(color: Colors.white),
);
},
),
onPressed: () {
_togglePlaybackSpeed();
}),
if (!isFullScreen)
IconButton(
icon: const Icon(Icons.fit_screen_outlined,
size: 25, color: Colors.white),
onPressed: () async {
_changeFitLabel(ref);
},
),
if (_isDesktop)
const MaterialDesktopFullscreenButton(iconSize: 25)
],
),
],
),
],
),
@ -840,7 +837,7 @@ class _AnimeStreamPageState extends riv.ConsumerState<AnimeStreamPage> {
seekGesture: true,
horizontalGestureSensitivity: 5000,
verticalGestureSensitivity: 300,
controlsHoverDuration: const Duration(seconds: 1000),
controlsHoverDuration: const Duration(seconds: 10),
volumeGesture: true,
brightnessGesture: true,
seekBarThumbSize: 15,
@ -895,7 +892,7 @@ class _AnimeStreamPageState extends riv.ConsumerState<AnimeStreamPage> {
}
: null,
icon: const Icon(
Icons.skip_previous_outlined,
Icons.skip_previous,
size: 30,
color: Colors.white,
),
@ -912,8 +909,7 @@ class _AnimeStreamPageState extends riv.ConsumerState<AnimeStreamPage> {
);
}
: null,
icon:
const Icon(Icons.skip_next_outlined, size: 30, color: Colors.white),
icon: const Icon(Icons.skip_next, size: 30, color: Colors.white),
),
const Spacer(flex: 3)
];
@ -924,10 +920,11 @@ class _AnimeStreamPageState extends riv.ConsumerState<AnimeStreamPage> {
bool isFullScreen) =>
MaterialDesktopVideoControlsThemeData(
visibleOnMount: true,
controlsHoverDuration: const Duration(seconds: 10),
controlsHoverDuration: const Duration(seconds: 2),
seekBarPositionColor: primaryColor(context),
seekBarThumbColor: primaryColor(context),
topButtonBarMargin: const EdgeInsets.all(0),
bottomButtonBarMargin: const EdgeInsets.all(0),
topButtonBar: _topButtonBar(context, isFullScreen),
buttonBarHeight: 100,
displaySeekBar: false,

View file

@ -218,7 +218,7 @@ class _MangaChapterPageGalleryState
late bool _isBookmarked = _readerController.getChapterBookmarked();
final _currentReaderMode = StateProvider<ReaderMode?>((ref) => null);
final _pageMode = StateProvider<PageMode?>((ref) => null);
PageMode? _pageMode;
bool _isView = false;
Alignment _scalePosition = Alignment.center;
final PhotoViewController _photoViewController = PhotoViewController();
@ -259,7 +259,7 @@ class _MangaChapterPageGalleryState
_processCropBorders();
final backgroundColor = ref.watch(backgroundColorStateProvider);
final cropBorders = ref.watch(cropBordersStateProvider);
final pageMode = ref.watch(_pageMode);
;
final l10n = l10nLocalizations(context)!;
return WillPopScope(
onWillPop: () async {
@ -314,10 +314,16 @@ class _MangaChapterPageGalleryState
if (_isView) {
_isViewFunction();
}
_readerController.setMangaHistoryUpdate();
_readerController.setPageIndex(
_geCurrentIndex(_uChapDataPreload[_currentIndex!].index!));
_isBookmarked = _readerController.getChapterBookmarked();
final currentIndex = ref.watch(currentIndexProvider(chapter));
int pagesLength = _pageMode == PageMode.doubleColumm
? (_uChapDataPreload.length / 2).ceil() + 1
: _uChapDataPreload.length;
if (currentIndex == pagesLength - 1) {
_readerController.setMangaHistoryUpdate();
_readerController.setPageIndex(
_geCurrentIndex(_uChapDataPreload[_currentIndex!].index!));
_isBookmarked = _readerController.getChapterBookmarked();
}
}
return true;
@ -343,7 +349,7 @@ class _MangaChapterPageGalleryState
initialScrollIndex:
_readerController.getPageIndex(),
itemCount:
pageMode == PageMode.doubleColumm
_pageMode == PageMode.doubleColumm
? (_uChapDataPreload.length / 2)
.ceil() +
1
@ -364,7 +370,7 @@ class _MangaChapterPageGalleryState
details.globalPosition);
},
onDoubleTap: () {},
child: pageMode ==
child: _pageMode ==
PageMode.doubleColumm
? DoubleColummVerticalView(
datas: index == 0
@ -415,7 +421,7 @@ class _MangaChapterPageGalleryState
: Material(
color: getBackgroundColor(backgroundColor),
shadowColor: getBackgroundColor(backgroundColor),
child: pageMode == PageMode.doubleColumm
child: _pageMode == PageMode.doubleColumm
? ExtendedImageGesturePageView.builder(
controller: _extendedController,
scrollDirection: _scrollDirection,
@ -715,7 +721,7 @@ class _MangaChapterPageGalleryState
void _readProgressListener() {
_currentIndex = _itemPositionsListener.itemPositions.value.first.index;
int pagesLength = ref.watch(_pageMode) == PageMode.doubleColumm
int pagesLength = _pageMode == PageMode.doubleColumm
? (_uChapDataPreload.length / 2).ceil() + 1
: _uChapDataPreload.length;
if (_currentIndex! >= 0 && _currentIndex! < pagesLength) {
@ -811,7 +817,11 @@ class _MangaChapterPageGalleryState
await Future.delayed(const Duration(milliseconds: 1));
ref.read(_currentReaderMode.notifier).state =
_readerController.getReaderMode();
ref.read(_pageMode.notifier).state = _readerController.getPageMode();
if (mounted) {
setState(() {
_pageMode = _readerController.getPageMode();
});
}
_setReaderMode(_readerController.getReaderMode(), ref);
ref.read(currentIndexProvider(chapter).notifier).setCurrentIndex(
_uChapDataPreload[_currentIndex!].index!,
@ -972,8 +982,8 @@ class _MangaChapterPageGalleryState
_setReaderMode(ReaderMode value, WidgetRef ref) async {
_failedToLoadImage.value = false;
_readerController.setReaderMode(value);
final pageMode = ref.watch(_pageMode);
int index = pageMode == PageMode.doubleColumm
int index = _pageMode == PageMode.doubleColumm
? (_currentIndex! / 2).ceil()
: _currentIndex!;
ref.read(_currentReaderMode.notifier).state = value;
@ -1076,7 +1086,6 @@ class _MangaChapterPageGalleryState
_readerController.getChaptersLength();
bool hasNextChapter = _readerController.getChapterIndex() != 0;
final readerMode = ref.watch(_currentReaderMode);
final pageMode = ref.watch(_pageMode);
return Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@ -1268,7 +1277,7 @@ class _MangaChapterPageGalleryState
);
} catch (_) {}
},
divisions: pageMode ==
divisions: _pageMode ==
PageMode.doubleColumm
? ((_readerController.getPageLength(
_chapterUrlModel
@ -1282,7 +1291,7 @@ class _MangaChapterPageGalleryState
1,
value: min(
(currentIndex).toDouble(),
pageMode == PageMode.doubleColumm
_pageMode == PageMode.doubleColumm
? ((_readerController.getPageLength(
_chapterUrlModel
.pageUrls)) /
@ -1297,7 +1306,8 @@ class _MangaChapterPageGalleryState
label:
_currentIndexLabel(currentIndex),
min: 0,
max: pageMode == PageMode.doubleColumm
max: _pageMode ==
PageMode.doubleColumm
? (((_readerController.getPageLength(
_chapterUrlModel
.pageUrls)) /
@ -1451,7 +1461,7 @@ class _MangaChapterPageGalleryState
PageMode newPageMode;
_onBtnTapped(
pageMode == PageMode.onePage
_pageMode == PageMode.onePage
? (_geCurrentIndex(
_uChapDataPreload[_currentIndex!]
.index!) /
@ -1462,14 +1472,19 @@ class _MangaChapterPageGalleryState
true,
isSlide: true,
);
newPageMode = pageMode == PageMode.onePage
newPageMode = _pageMode == PageMode.onePage
? PageMode.doubleColumm
: PageMode.onePage;
ref.read(_pageMode.notifier).state = newPageMode;
_readerController.setPageMode(newPageMode);
if (mounted) {
setState(() {
_pageMode = newPageMode;
});
}
},
icon: Icon(
pageMode == PageMode.doubleColumm
_pageMode == PageMode.doubleColumm
? CupertinoIcons.book_solid
: CupertinoIcons.book,
),
@ -1532,7 +1547,7 @@ class _MangaChapterPageGalleryState
}
String _currentIndexLabel(int index) {
if (ref.watch(_pageMode) != PageMode.doubleColumm) {
if (_pageMode != PageMode.doubleColumm) {
return "${index + 1}";
}
if (index == 0) {
@ -1545,7 +1560,7 @@ class _MangaChapterPageGalleryState
}
int _geCurrentIndex(int index) {
if (ref.watch(_pageMode) != PageMode.doubleColumm || index == 0) {
if (_pageMode != PageMode.doubleColumm || index == 0) {
return index;
}
int pageLength = _readerController.getPageLength(_chapterUrlModel.pageUrls);
@ -1750,12 +1765,12 @@ class _MangaChapterPageGalleryState
title: Text(
l10n.crop_borders,
style: TextStyle(
fontSize: 17,
color: Theme.of(context)
.textTheme
.bodyLarge!
.color!
.withOpacity(0.9)),
.withOpacity(0.9),
fontSize: 14),
),
onChanged: (value) {
ref
@ -1849,12 +1864,12 @@ class _MangaChapterPageGalleryState
title: Text(
l10n.show_page_number,
style: TextStyle(
fontSize: 17,
color: Theme.of(context)
.textTheme
.bodyLarge!
.color!
.withOpacity(0.9)),
.withOpacity(0.9),
fontSize: 14),
),
onChanged: (value) {
ref
@ -1865,8 +1880,16 @@ class _MangaChapterPageGalleryState
}),
SwitchListTile(
value: animatePageTransitions,
title:
Text(l10n.animate_page_transitions),
title: Text(
l10n.animate_page_transitions,
style: TextStyle(
color: Theme.of(context)
.textTheme
.bodyLarge!
.color!
.withOpacity(0.9),
fontSize: 14),
),
onChanged: (value) {
ref
.read(
@ -1942,15 +1965,10 @@ class CustomPopupMenuButton<T> extends StatelessWidget {
Icons.check,
color: d == value ? Colors.white : Colors.transparent,
),
const SizedBox(
width: 7,
),
const SizedBox(width: 7),
Text(
itemText(d),
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
style: const TextStyle(color: Colors.white),
),
],
)),
@ -1964,7 +1982,6 @@ class CustomPopupMenuButton<T> extends StatelessWidget {
child: Text(
label,
style: TextStyle(
fontSize: 17,
color: Theme.of(context)
.textTheme
.bodyLarge!
@ -1976,13 +1993,8 @@ class CustomPopupMenuButton<T> extends StatelessWidget {
),
Row(
children: [
Text(
title,
style: const TextStyle(fontSize: 17),
),
const SizedBox(
width: 20,
),
Text(title),
const SizedBox(width: 20),
const Icon(Icons.keyboard_arrow_down_outlined)
],
),
@ -1993,110 +2005,3 @@ class CustomPopupMenuButton<T> extends StatelessWidget {
);
}
}
class ViewPage extends StatefulWidget {
final ImageProvider<Object> imageProvider;
final Function(double) scale;
const ViewPage({super.key, required this.imageProvider, required this.scale});
@override
State<ViewPage> createState() => _ViewPageState();
}
class _ViewPageState extends State<ViewPage> with TickerProviderStateMixin {
late AnimationController _scaleAnimationController;
late Animation<double> _animation;
Alignment _scalePosition = Alignment.center;
final PhotoViewController _photoViewController = PhotoViewController();
final PhotoViewScaleStateController _photoViewScaleStateController =
PhotoViewScaleStateController();
Duration? _doubleTapAnimationDuration() {
int doubleTapAnimationValue =
isar.settings.getSync(227)!.doubleTapAnimationSpeed!;
if (doubleTapAnimationValue == 0) {
return const Duration(milliseconds: 10);
} else if (doubleTapAnimationValue == 1) {
return const Duration(milliseconds: 800);
}
return const Duration(milliseconds: 200);
}
void _onScaleEnd(BuildContext context, ScaleEndDetails details,
PhotoViewControllerValue controllerValue) {
if (controllerValue.scale! < 1) {
_photoViewScaleStateController.reset();
}
}
double get pixelRatio => View.of(context).devicePixelRatio;
Size get size => View.of(context).physicalSize / pixelRatio;
Alignment _computeAlignmentByTapOffset(Offset offset) {
return Alignment((offset.dx - size.width / 2) / (size.width / 2),
(offset.dy - size.height / 2) / (size.height / 2));
}
@override
void initState() {
_scaleAnimationController = AnimationController(
duration: _doubleTapAnimationDuration(), vsync: this);
_animation = Tween(begin: 1.0, end: 2.0).animate(
CurvedAnimation(curve: Curves.ease, parent: _scaleAnimationController));
_animation.addListener(() {
_photoViewController.scale = _animation.value;
widget.scale(_animation.value);
});
super.initState();
}
void _toggleScale(Offset tapPosition) {
if (mounted) {
setState(() {
if (_scaleAnimationController.isAnimating) {
return;
}
if (_photoViewController.scale == 1.0) {
_scalePosition = _computeAlignmentByTapOffset(tapPosition);
if (_scaleAnimationController.isCompleted) {
_scaleAnimationController.reset();
}
_scaleAnimationController.forward();
return;
}
if (_photoViewController.scale == 2.0) {
_scaleAnimationController.reverse();
return;
}
_photoViewScaleStateController.reset();
});
}
}
@override
Widget build(BuildContext context) {
return PhotoViewGallery.builder(
backgroundDecoration: const BoxDecoration(color: Colors.transparent),
itemCount: 1,
builder: (context, index) {
return PhotoViewGalleryPageOptions.customChild(
controller: _photoViewController,
scaleStateController: _photoViewScaleStateController,
basePosition: _scalePosition,
onScaleEnd: _onScaleEnd,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onDoubleTapDown: (TapDownDetails details) {
_toggleScale(details.globalPosition);
},
onDoubleTap: () {},
child: Image(image: widget.imageProvider),
));
},
);
}
}

View file

@ -0,0 +1,30 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
/pubspec.lock
**/doc/api/
.dart_tool/
.packages
build/

View file

@ -0,0 +1,42 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled.
version:
revision: 12cb4eb7a009f52b347b62ade7cb4854b926af72
channel: stable
project_type: plugin
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 12cb4eb7a009f52b347b62ade7cb4854b926af72
base_revision: 12cb4eb7a009f52b347b62ade7cb4854b926af72
- platform: android
create_revision: 12cb4eb7a009f52b347b62ade7cb4854b926af72
base_revision: 12cb4eb7a009f52b347b62ade7cb4854b926af72
- platform: ios
create_revision: 12cb4eb7a009f52b347b62ade7cb4854b926af72
base_revision: 12cb4eb7a009f52b347b62ade7cb4854b926af72
- platform: linux
create_revision: 12cb4eb7a009f52b347b62ade7cb4854b926af72
base_revision: 12cb4eb7a009f52b347b62ade7cb4854b926af72
- platform: macos
create_revision: 12cb4eb7a009f52b347b62ade7cb4854b926af72
base_revision: 12cb4eb7a009f52b347b62ade7cb4854b926af72
- platform: windows
create_revision: 12cb4eb7a009f52b347b62ade7cb4854b926af72
base_revision: 12cb4eb7a009f52b347b62ade7cb4854b926af72
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

View file

@ -0,0 +1,747 @@
## 7.12.3
Issue #189 related to resume on Android versions prior to S, and to expediting a task prior to S
See https://stackoverflow.com/a/68468786/4172761
Fixes issue with parsing priority from JSON
Fixes issue with setting expedited for Android versions prior to S. This effectively ignores priority (expedited) scheduling for tasks prior to Android S and defaults to normal.
## 7.12.2
Minor improvements to `TaskQueue` and `MemoryTaskQueue`
## 7.12.1
Bug fix for web compilation
## 7.12.0
### Task priority levels
The `Task.priority` field must be 0 <= priority <= 10 with 0 being the highest priority, and defaults to 5. On Desktop and iOS all priority levels are supported. On Android, priority levels <5 are handled as 'expedited', and >=5 is handled as a normal task.
### Task queues
Once you `enqueue` a task with the `FileDownloader` it is added to an internal queue that is managed by the native platform you're running on (e.g. Android). Once enqueued, you have limited control over the execution order, the number of tasks running in parallel, etc, because all that is managed by the platform. If you want more control over the queue, you need to add a `TaskQueue`.
The `MemoryTaskQueue` bundled with the `background_downloader` allows:
* pacing the rate of enqueueing tasks, based on `minInterval`, to avoid 'choking' the FileDownloader when adding a large number of tasks
* managing task priorities while waiting in the queue, such that higher priority tasks are enqueued before lower priority ones
* managing the total number of tasks running concurrently, by setting `maxConcurrent`
* managing the number of tasks that talk to the same host concurrently, by setting `maxConcurrentByHost`
* managing the number of tasks running that are in the same `Task.group`, by setting `maxConcurrentByGroup`
A `TaskQueue` conceptually sits 'before' the FileDownloader's queue. To use it, add it to the `FileDownloader` and instead of enqueuing tasks with the `FileDownloader`, you now `add` tasks to the queue:
```dart
final tq = MemoryTaskQueue();
tq.maxConcurrent = 5; // no more than 5 tasks active at any one time
tq.maxConcurrentByHost = 2; // no more than two tasks talking to the same host at the same time
tq.maxConcurrentByGroup = 3; // no more than three tasks from the same group active at the same time
FileDownloader().add(tq); // 'connects' the TaskQueue to the FileDownloader
FileDownloader().updates.listen((update) { // listen to updates as per usual
print('Received update for ${update.task.taskId}: $update')
});
for (var n = 0; n < 100; n++) {
task = DownloadTask(url: workingUrl, metData: 'task #$n'); // define task
tq.add(task); // add to queue. The queue makes the FileDownloader().enqueue call
}
```
Because it is possible that an error occurs when the taskQueue eventually actually enqueues the task with the FileDownloader, you can listen to the `enqueueErrors` stream for tasks that failed to enqueue.
The default `TaskQueue` is the `MemoryTaskQueue` which, as the name suggests, keeps everything in memory. This is fine for most situations, but be aware that the queue may get dropped if the OS aggressively moves the app to the background. Tasks still waiting in the queue will not be enqueued, and will therefore be lost. If you want a `TaskQueue` with more persistence, subclass the `MemoryTaskQueue` and add persistence.
In addition, if your app is supended by the OS due to resource constraints, tasks waiting in the queue will not be enqueued to the native platform and will not run in the background. TaskQueues are therefore best for situations where you expect the queue to be emptied while the app is still in the foreground.
## 7.11.1
Fix #164 for progress updates for uploads.
## 7.11.0
### Android external storage
Add configuration for Android to use external storage instead of internal storage. Either your app runs in default (internal storage) mode, or in external storage. You cannot switch between internal and external, as the directory structure that - for example - `BaseDirectory.applicationDocuments` refers to is different in each mode. See the [configuration document](https://github.com/781flyingdutchman/background_downloader/blob/main/CONFIG.md) for important details and limitations
Use `(Config.useExternalStorage, String whenToUse)` with values 'never' or 'always'. Default is `Config.never`.
### Server suggested filename
If you want the filename to be provided by the server (instead of assigning a value to `filename` yourself), you now have two options. The first is to create a `DownloadTask` that pings the server to determine the suggested filename:
```dart
final task = await DownloadTask(url: 'https://google.com')
.withSuggestedFilename(unique: true);
```
The method `withSuggestedFilename` returns a copy of the task it is called on, with the `filename` field modified based on the filename suggested by the server, or the last path segment of the URL, or unchanged if neither is feasible (e.g. due to a lack of connection). If `unique` is true, the filename will be modified such that it does not conflict with an existing filename by adding a sequence. For example "file.txt" would become "file (1).txt". You can now also supply a `taskWithFilenameBuilder` to suggest the filename yourself, based on response headers.
The second approach is to set the `filename` field of the `DownloadTask` to `DownloadTask.suggestedFilename`, to indicate that you would like the server to suggest the name. In this case, you will receive the name via the task's status and/or progress updates, so you have to be careful _not_ to use the original task's filename, as that will still be `DownloadTask.suggestedFilename`. For example:
```dart
final task = await DownloadTask(url: 'https://google.com', filename: DownloadTask.suggestedFilename);
final result = await FileDownloader().download(task);
print('Suggested filename=${result.task.filename}'); // note we don't use 'task', but 'result.task'
print('Wrong use filename=${task.filename}'); // this will print '?' as 'task' hasn't changed
```
### Set content length if not provided by server
To provide progress updates (as a percentage of total file size) the downloader needs to know the size of the file when starting the download. Most servers provide this in the "Content-Length" header of their response. If the server does not provide the file size, yet you know the file size (e.g. because you have stored the file on the server yourself), then you can let the downloader know by providing a `{'Range': 'bytes=0-999'}` or a `{'Known-Content-Length': '1000'}` header to the task's `header` field. Both examples are for a content length of 1000 bytes. The downloader will assume this content length when calculating progress.
### Bug fix
Partial Downloads, using a Range header, can now be properly paused on all platforms.
## 7.10.1
Add `displayName` field to `Task` that can be used to store and dsipay a 'human readable' description of the task. It can be displayed in a notification using {displayName}.
Bug fix for regression in compiling for Web platform (through stubbing - no actual web functionality).
## 7.10.0
Add `ParallelDownloadTask`. Some servers may offer an option to download part of the same file from multiple URLs or have multiple parallel downloads of part of a large file using a single URL. This can speed up the download of large files. To do this, create a `ParallelDownloadTask` instead of a regular `DownloadTask` and specify `chunks` (the number of pieces you want to break the file into, i.e. the number of downloads that will happen in parallel) and `urls` (as a list of URLs, or just one). For example, if you specify 4 chunks and 2 URLs, then the download will be broken into 8 pieces, four each for each URL.
Note that the implementation of this feature creates a regular `DownloadTask` for each chunk, with the group name 'chunk' which is now a reserved group. You will not get updates for this group, but you will get normal updates (status and/or progress) for the `ParallelDownloadTask`.
## 7.9.4
Enable compile for Web platform (through stubbing - no actual web functionality).
Automatically dismiss "complete" and "error" notifications when the user taps on the notification.
## 7.9.3
Bug fix for validating URLs to allow localhost URLs.
Update to Android Gradle Plugin 8.1.0
## 7.9.2
Add configuration `Config.useCacheDir` for Android and improved temp file logic. By default (`Config.whenAble`) the downloader will now use the application's `cacheDir` when the size of the file to download is less than half of the `cacheQuotaBytes` given to the app by Android, and use `filesDir` otherwise. If you find that downloads do not complete (or cannot be resumed when paused) this indicates the OS is removing the temp file from the `cacheDir` due to low memory conditions. In that situation, consider using `Config.never` to force the use of `filesDir`, but make sure to clean up remnant temp files in `filesDir`, as the OS does not do that for you.
Fix for Android 33 related to the new [predictive back gesture navigation](https://developer.android.com/guide/navigation/custom-back/predictive-back-gesture)
Documentation updates
## 7.9.1
Failed download tasks can now be resumed (under certain conditions) even if the `Task.allowPause` field is false. Resuming a failed task will attempt to continue the download where the failure occurred. If `retries` are set to a value >0 then retries will also first attempt to resume, and only start from scratch if that fails.
Tasks can only resume if the ETag header provided by the server is strong, and equal to the ETag at the moment the download was paused/failed, or if it is not provided at all.
## 7.9.0
### Configuration
Add configuration of the downloader for several aspects:
* Running tasks in 'foreground mode' on Android to allow longer runs and prevent the OS killing some tasks when the app is in the background
* Setting the request timeout value and, for iOS only, the 'resourceTimeout'
* Checking available space before attempting a download
* Setting a proxy
* Localizing the notification button texts on iOS
* Bypassing TLS Certificate validation (for debug mode only)
Please read the [configuration document](https://github.com/781flyingdutchman/background_downloader/blob/main/CONFIG.md) for details on how to configure.
Configuration is experimental, so please test thoroughly before using in production, and let me know if there are any issues.
### Network speed and time remaining in `TaskStatusUpdate`
`TaskStatusUpdate` now has fields `networkSpeed` (in MB/s) and `timeRemaining`. Check the associated `hasNetworkSpeed` and `hasTimeRemaining` before using the values in these fields. Use `networkSpeedAsString` and `timeRemainingAsString` for human readable versions of these values.
### Filter `TaskRecord` entries by status: `allRecordsWithStatus`
The `database` now has method `allRecordsWithStatus` to filter records based on their `TaskStatus`
## 7.8.1
Bug fix for `taskNotificationTapCallback`: convenience methods that `await` a result, such as `download` (but not `enqueue`), now use the default `taskNotificationTapCallback`, even though those tasks are in the `awaitGroup`, because that behavior is more in line with expectations. If you need a separate callback for the `awaitGroup`, then set it _after_ setting the default callback. You set the default callback by omitting the `group` parameter in the `registerCallbacks` call.
## 7.8.0
Added field `responseBody` to `TaskStatusUpdate` that, if not null, contains the server response for uploads, and for downloads that are not complete (e.g. `.notFound`). In those instances, the server response may contain useful information (e.g. a url where the uploaded file can be found, or the reason for the 'not found' status as provided by the server)
Improved handling of notification tap callbacks.
## 7.7.1
Bug fix for Flutter Downloader migration on iOS, issue #86
## 7.7.0
### Uploading multiple files in a single request
If you need to upload multiple files in a single request, create a [MultiUploadTask](https://pub.dev/documentation/background_downloader/latest/background_downloader/MultiUploadTask-class.html) instead of an `UploadTask`. It has similar parameters as the `UploadTask`, except you specifiy a list of files to upload as the `files` argument of the constructor, and do not use `fileName`, `fileField` and `mimeType`. Each element in the `files` list is either:
* a filename (e.g. `"file1.txt"`). The `fileField` for that file will be set to the base name (i.e. "file1" for "file1.txt") and the mime type will be derived from the extension (i.e. "text/plain" for "file1.txt")
* a record containing `(fileField, filename)`, e.g. `("document", "file1.txt")`. The `fileField` for that file will be set to "document" and the mime type derived from the file extension (i.e. "text/plain" for "file1.txt")
* a record containing `(filefield, filename, mimeType)`, e.g. `("document", "file1.txt", "text/plain")`
The `baseDirectory` and `directory` fields of the `MultiUploadTask` determine the expected location of the file referenced, unless the filename used in any of the 3 formats above is an absolute path (e.g. "/data/user/0/com.my_app/file1.txt"). In that case, the absolute path is used and the `baseDirectory` and `directory` fields are ignored for that element of the list.
Once the `MultiUpoadTask` is created, the fields `fileFields`, `filenames` and `mimeTypes` will contain the parsed items, and the fields `fileField`, `filename` and `mimeType` contain those lists encoded as a JSON string.
Use the `MultiTaskUpload` object in the `upload` and `enqueue` methods as you would a regular `UploadTask`.
### Flutter Downloader migration
Bug fixes related to migration from Flutter Downloader (see version 7.6.0). The migration is still experimental, so please test thoroughly before relying on the migration in your app.
### Bug fixes
Fixed a bug on iOS related to NSNull Json decoding
## 7.6.0
Added `SqlitePersistentStorage` as an alternative backing storage for the downloader, and implemented migration of a pre-existing database from the Flutter Downloader package. We use the `sqflite` package, so this is only supported iOS and Android.
To use the downloader with SQLite backing and migration from Flutter Downloader, initialize the `FileDownloader` at the very beginning of your app:
```dart
final sqlStorage = SqlitePersistentStorage(migrationOptions: ['flutter_downloader', 'local_store']);
FileDownloader(persistentStorage: sqlStorage);
// start using the FileDownloader
```
This will migrate from either Flutter Downloader or the default LocalStore.
Added an optional parameter to the tasksFinished method that allows you to use it the moment you receive a status update for a task, like this:
```dart
void downloadStatusCallback(TaskStatusUpdate update) async {
// process your status update, then check if all tasks are finished
final bool allTasksFinished = update.status.isFinalState &&
await FileDownloader().tasksFinished(ignoreTaskId: update.task.taskId) ;
print('All tasks finished: $allTasksFinished');
}
```
This excludes the task that is currently finishing up from the test. Without this, it's possible `tasksFinished` returns `false` as that currently finishing task may not have left the queue yet.
## 7.5.0
Added `pathInSharedStorage` method, which obtains the path to a file moved to shared storage.
To check if a file exists in shared storage, obtain the path to the file by calling
`pathInSharedStorage` and, if not null, check if that file exists.
__On Android 29+:__ If you
have generated a version with an indexed name (e.g. 'myFile (1).txt'), then only the most recently stored version is available this way, even if an earlier version actually does exist. Also, only files stored by your app will be returned via this call, as you don't have access to files stored by other apps.
__On iOS:__ To make files visible in the Files browser, do not move them to shared storage. Instead, download the file to the `BaseDirectory.applicationDocuments` and add the following to your `Info.plist`:
```
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UIFileSharingEnabled</key>
<true/>
```
This will make all files in your app's `Documents` directory visible to the Files browser.
Bug fixes:
* Fixed bug when download is interrupted due to lost network connection (on Android)
* Fixed bug with `moveToSharedStorage` on iOS: shared storage is now 'faked' on iOS, creating
subdirectories of the regular Documents directory, as iOS apps do not have access to shared
media and download directories
* Fixed bug with notifications disappearing on iOS
## 7.4.1
Bug fix for type cast errors and for thread safety on iOS for notifications
## 7.4.0
Added method `expectedFileSize()` to `DownloadTask`, and added field `expectedFileSize` to
`TaskProgressUpdate` (provided to callbacks or listeners during download), and `TaskRecord`
entries in the database.
Note that this field is only valid when 0 < progress < 1. It is -1 if file size cannot be determined.
## 7.3.1
Improved [DownloadProgressIndicator](https://pub.dev/documentation/background_downloader/latest/background_downloader/DownloadProgressIndicator-class.html) widget:
* In collapsed state, now shows progress as 'n' files finished out of 'total' started (and progress as that fraction)
* Option to force collapsed state always by setting `maxExpandable` to 0. When set to 1, the indicator collapses only when the second download starts. When set greater than 1, the indicator expands to show multiple simultaneous downloads.
Added usage examples upfront in the readme
## 7.3.0
Added [DownloadProgressIndicator](https://pub.dev/documentation/background_downloader/latest/background_downloader/DownloadProgressIndicator-class.html) widget and modified the example app to show how to wire it up.
The widget is configurable (e.g. pause and cancel buttons) and can show multiple downloads simultaneously in either an expanded
or collapsed mode.
If tracking downloads in persistent storage, pausing a file now does not override the stored progress with `progressPaused`.
Fixed bugs.
## 7.2.0
Added option to use a different persistent storage solution than the one provided by default. The downloader stores a few things in persistent storage, and uses a modified version of the [localstore](https://pub.dev/packages/localstore) package by default. To use a different persistent storage solution, create a class that implements the [PersistentStorage](https://pub.dev/documentation/background_downloader/latest/background_downloader/PersistentStorage-class.html) interface, and initialize the downloader by calling `FileDownloader(persistentStorage: yourStorageClass())` as the first use of the `FileDownloader`.
A simple example is included in the example app (using the [sqflite](https://pub.dev/packages/sqflite) package).
Fixed a few bugs.
## 7.1.0
Added `tasksFinished` method that returns `true` if all tasks in the group have finished
Fixed bug related to `allTasks` method
## 7.0.2
Added `namespace` to Android build.gradle and removed irrelevant log messages
Fixed permission bug on Android 10
Changed class modifiers to allow mocking with Mockito
## 7.0.1
Migrating the persistent data from the documents directory to the support directory, so it is no longer visible in - for example - the iOS Files app, or the Linux home directory.
Further Dart 3 changes (not visible to user).
## 7.0.0
Migration to Dart 3 - not other functional change or API change. If you use Dart 2 please use version `6.1.1` of this plugin, which will be maintained until the end of 2023.
Most classes in the package are now `final` classes, and under the hood we use the new Records and Pattern matching features of Dart 3. None of this should matter if you've used the package as intended.
## 6.3.2
Fixed a bug on iOS related to NSNull Json decoding
## 6.3.1
Added an optional parameter to the tasksFinished method that allows you to use it the moment you receive a status update for a task, like this:
```dart
void downloadStatusCallback(TaskStatusUpdate update) async {
// process your status update, then check if all tasks are finished
final bool allTasksFinished = update.status.isFinalState &&
await FileDownloader().tasksFinished(ignoreTaskId: update.task.taskId) ;
print('All tasks finished: $allTasksFinished');
}
```
This excludes the task that is currently finishing up from the test. Without this, it's possible `tasksFinished` returns `false` as that currently finishing task may not have left the queue yet.
## 6.3.0
Added `pathInSharedStorage` method, which obtains the path to a file moved to shared storage.
To check if a file exists in shared storage, obtain the path to the file by calling
`pathInSharedStorage` and, if not null, check if that file exists.
__On Android 29+:__ If you
have generated a version with an indexed name (e.g. 'myFile (1).txt'), then only the most recently stored version is available this way, even if an earlier version actually does exist. Also, only files stored by your app will be returned via this call, as you don't have access to files stored by other apps.
__On iOS:__ To make files visible in the Files browser, do not move them to shared storage. Instead, download the file to the `BaseDirectory.applicationDocuments` and add the following to your `Info.plist`:
```
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UIFileSharingEnabled</key>
<true/>
```
This will make all files in your app's `Documents` directory visible to the Files browser.
Bug fixes:
* Fixed bug when download is interrupted due to lost network connection (on Android)
* Fixed bug with `moveToSharedStorage` on iOS: shared storage is now 'faked' on iOS, creating
subdirectories of the regular Documents directory, as iOS apps do not have access to shared
media and download directories
* Fixed bug with notifications disappearing on iOS
## 6.2.1
Bug fix for type cast errors and for thread safety on iOS for notifications
## 6.2.0
Added `tasksFinished` method that returns `true` if all tasks in the group have finished
Fixed bug related to `allTasks` method
## 6.1.4
Fixed permission bug on Android 10
## 6.1.3
Added `namespace` to Android build.gradle and removed irrelevant log messages
## 6.1.2
Migrating the persistent data from the documents directory to the support directory, so it is no longer visible in - for example - the iOS Files app, or the Linux home directory.
## 6.1.1
Bug fix for `request` method where the `httpRequestMethod` override was not taken into account properly.
## 6.1.0
Added `unregisterCallBacks` to remove callbacks if you no longer want updates, and `resetUpdates` to reset the `updates` stream so it can be listened to again.
Bug fix for `DownloadTask.withSuggestedFilename` for servers that do not follow case convention for the Content-Disposition header.
## 6.0.0
Breaking changes:
* The `TaskStatusCallback` and `TaskProgressCallback` now take a single argument (`TaskStatusUpdate` and `TaskProgressUpdate` respectively) instead of multiple arguments. This aligns the callback API with the `updates` listener API, and makes it easier to add data to an update in the future. For example, in this version we add an `exception` property to programmatically handle exceptions
* Similarly, the `download` and `upload` methods now return a `TaskStatusUpdate` instead of a `TaskStatus`
* For consistency, the `taskStatus` property of the `TaskRecord` (used to store task information in a persistent database) is renamed to `status`
* The `trackTasks` method no longer takes a `group` argument, and starts tracking for all tasks, regardless of group. If you need tracking only for a specific group, call the new `trackTasksInGroup` method
Other changes (non-breaking):
* You can override the `httpRequestMethod` used for requests by setting it in the `Request`, `DownloadTask` or `UploadTask`. By default, requests and downloads use GET (unless `post` is set) and uploads use POST
* The `download`, `upload`, `downloadBatch` and `uploadBatch` methods now take an optional `onElapsedTime` callback that is called at regular intervals (defined by the optional `elapsedTimeInterval` which defaults to 5 seconds) with the time elapsed since the call was made. 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, and this mechanism should not be used to indicate progress.
* If a task fails, the `TaskStatusUpdate` will contain a `TaskException` that provides information about the type of exception (e.g. a `TaskFileSystemException` indicates an issue with storing or retrieving the file) and contains a `description` and (for `TaskHttpException` only) the `httpResponseCode`. If tasks are tracked, the The following `TaskException` subtypes may occur:
- `TaskException` (general exception)
- `TaskFileSystemException` (issue retrieving or storing the file)
- `TaskUrlException` (issue with the url)
- `TaskConnectionException` (issue with the connection to the server)
- `TaskResumeException` (issue with pausing or resuming a task)
- `TaskHttpException` (issue with the HTTP connection, e.g. we received an error response from the server, captured in `httpResponseCode`)
Fixed a few bugs.
## 5.6.0
Adds handler for when the user taps a notification, and an `openFile` method to open a file using the platform-specific convention.
To handle notification taps, register a callback that takes `Task` and `NotificationType` as parameters:
```
FileDownloader().registerCallbacks(
taskNotificationTapCallback: myNotificationTapCallback);
void myNotificationTapCallback(Task task, NotificationType notificationType) {
print('Tapped notification $notificationType for taskId ${task.taskId}');
}
```
To open a file, call `FileDownloader().openFile` and supply either a `Task` or a full `filePath` (but not both) and optionally a `mimeType` to assist the Platform in choosing the right application to use to open the file.
The file opening behavior is platform dependent, and while you should check the return value of the call to `openFile`, error checking is not fully consistent.
Note that on Android, files stored in the `BaseDirectory.applicationDocuments` cannot be opened. You need to download to a different base directory (e.g. `.applicationSupport`) or move the file to shared storage before attempting to open it.
If all you want to do on notification tap is to open the file, you can simplify the process by
adding `tapOpensFile: true` to your call to `configureNotifications`, and you don't need to
register a `taskNotificationTapCallback`.
## 5.5.0
Adds `withSuggestedFilename` for `DownloadTask`. Use:
```
final task = await DownloadTask(url: 'https://google.com')
.withSuggestedFilename(unique: true);
```
The method `withSuggestedFilename` returns a copy of the task it is called on, with the `filename` field modified based on the filename suggested by the server, or the last path segment of the URL, or unchanged if neither is feasible. If `unique` is true, the filename will be modified such that it does not conflict with an existing filename by adding a sequence. For example "file.txt" would become "file (1).txt".
Bug fixes:
* Fix for issue #35 for pausing convenience download and a specific issue with nginx related to pause/resume
* Fix for issue #38 related to notification permissions on iOS
## 5.4.6
Fix issue #34 with `moveToSharedStorage` on iOS
## 5.4.5
An invalid url in the `Task` now results in `false` being returned from the `enqueue` call on
all platforms. Previously, the behavior was inconsistent.
## 5.4.4
Added optional properties to `UploadTask` related to multi-part uploads:
* `fileField` is the field name used to indicate the file (default to "file")
* `mimeType` overrides the mimeType derived from the filename extension
* `fields` is a `Map<String, String>` containing form field name/value pairs that will be uploaded along with the file in a multi-part upload
## 5.4.3
Added optional `mimeType` parameter for calls to `moveToSharedStorage` and
`moveFileToSharedStorage`. This sets the mimeType
directly, instead of relying on the system to determine the mime type based on the file extension.
Note that this may change the filename - for example, when moving the test file `google.html` to
`SharedStorage.images` while setting `mimeType` to 'images/jpeg', the path to the file in shared
storage becomes `/storage/emulated/0/Pictures/google.html.jpg` (note the added .jpg).
## 5.4.2
Better permissions management, implementation of moveToSharedStorage for Android versions below Q
## 5.4.1
Minor fixes
## 5.4.0
### Shared and scoped storage
The download directories specified in the `BaseDirectory` enum are all local to the app. To make downloaded files available to the user outside of the app, or to other apps, they need to be moved to shared or scoped storage, and this is platform dependent behavior. For example, to move the downloaded file associated with a `DownloadTask` to a shared 'Downloads' storage destination, execute the following _after_ the download has completed:
```
final newFilepath = await FileDownloader().moveToSharedStorage(task, SharedStorage.downloads);
if (newFilePath == null) {
... // handle error
} else {
... // do something with the newFilePath
}
```
Because the behavior is very platform-specific, not all `SharedStorage` destinations have the same result. The options are:
* `.downloads` - implemented on all platforms, but on iOS files in this directory are not accessible to other users
* `.images` - implemented on Android and iOS only. On iOS files in this directory are not accessible to other users
* `.video` - implemented on Android and iOS only. On iOS files in this directory are not accessible to other users
* `.audio` - implemented on Android and iOS only. On iOS files in this directory are not accessible to other users
* `.files` - implemented on Android only
* `.external` - implemented on Android only
On MacOS, for the `.downloads` to work you need to enable App Sandbox entitlements and set the key `com.apple.security.files.downloads.read-write` to true.
On Android, depending on what `SharedStorage` destination you move a file to, and depending on the OS version your app runs on, you _may_ require extra permissions `WRITE_EXTERNAL_STORAGE` and/or `READ_EXTERNAL_STORAGE` . See [here](https://medium.com/androiddevelopers/android-11-storage-faq-78cefea52b7c) for details on the new scoped storage rules starting with Android API version 30, which is what the plugin is using.
Methods `moveToSharedStorage` and the similar `moveFileToSharedStorage` also take an optional `directory` argument for a subdirectory in the `SharedStorage` destination.
Thanks to @rebaz94 for implementing scoped storage on Android.
### Library base directory
The `BaseDirectory` enum now also supports `.applicationLibrary`. On iOS and MacOS this is the directory provided by the `path_provider` package's `getLibraryDirectory()` call. On Other platforms, for consistency, this is the subdirectory 'Library' of the directory returned byn the `getApplicationSupportDirectory()` call.
### Bug fix
Fixed a bug with iOS cancellation in non-US locales.
## 5.3.0
### Notifications
On iOS and Android, for downloads only, the downloader can generate notifications to keep the user informed of progress also when the app is in the background, and allow pause/resume and cancellation of an ongoing download from those notifications.
Configure notifications by calling `FileDownloader().configureNotification` and supply a `TaskNotification` object for different states. For example, the following configures notifications to show only when actively running (i.e. download in progress), disappearing when the download completes or ends with an error. It will also show a progress bar and a 'cancel' button, and will substitute {filename} with the actual filename of the file being downloaded.
```
FileDownloader().configureNotification(
running: TaskNotification('Downloading', 'file: {filename}'),
progressBar: true)
```
To also show a notifications for other states, add a `TaskNotification` for `complete`, `error` and/or `paused`. If `paused` is configured and the task can be paused, a 'Pause' button will show for the `running` notification, next to the 'Cancel' button.
There are three possible substitutions of the text in the `title` or `body` of a `TaskNotification`:
* {filename} is replaced with the filename as defined in the `Task`
* {progress} is substituted by a progress percentage, or '--%' if progress is unknown
* {metadata} is substituted by the `Task.metaData` field
Notifications on iOS follow Apple's [guidelines](https://developer.apple.com/design/human-interface-guidelines/components/system-experiences/notifications/), notably:
* No progress bar is shown, and the {progress} substitution always substitutes to an empty string. In other words: only a single `running` notification is shown and it is not updated until the download state changes
* When the app is in the foreground, on iOS 14 and above the notification will not be shown but will appear in the NotificationCenter. On older iOS versions the notification will be shown also in the foreground. Apple suggests showing progress and download controls within the app when it is in the foreground
While notifications are possible on desktop platforms, there is no true background mode, and progress updates and indicators can be shown within the app. Notifications are therefore ignored on desktop platforms.
The `configureNotification` call configures notification behavior for all download tasks. You can specify a separate configuration for a `group` of tasks by calling `configureNotificationForGroup` and for a single task by calling `configureNotificationForTask`. A `Task` configuration overrides a `group` configuration, which overrides the default configuration.
When attempting to show its first notification, the downloader will ask the user for permission to show notifications (platform version dependent) and abide by the user choice. For Android, starting with API 33, you need to add `<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />` to your app's `AndroidManifest.xml`. Also on Android you can localize the button text by overriding string resources `bg_downloader_cancel`, `bg_downloader_pause`, `bg_downloader_resume` and descriptions `bg_downloader_notification_channel_name`, `bg_downloader_notification_channel_description`. Localization on iOS is not currently supported.
## 5.2.0
Better persistence for tasks that execute while the app is suspended by the operating system.
To ensure your callbacks or listener capture events that may have happened when your app was
suspended in the background, call `FileDownloader().resumeFromBackground()` right after registering
your callbacks or listener.
## 5.1.0
Previously, Android file downloads were limited to 8 minutes. Now, long downloads are possible provided the `DownloadTask.allowPause` field is set to true. Just before the download times out, the downloader will pause and then resume the task in a new worker, effectively resetting the 9 minute clock. As a result, the download will eventually complete
## 5.0.0
### Pause and resume
To pause or resume a task, call:
* `pause` to attempt to pause a task. Whether a task can be canceled or not depends primarily on the server. Soon after the task is running (`TaskStatus.running`) you can call `taskCanResume` which will return a Future that resolves to `true` if the server appears capable of pause & resume. If that returns `false`, then calling `pause` will return `false` as well, and the call is ignored
* `resume` to resume a previously paused task, which returns true if resume appears feasible. The taskStatus will follow the same sequence as a newly enqueued task. If resuming turns out to be not feasible (e.g. the operating system deleted the temp file with the partial download) then the task will either restart as a normal download, or fail.
This adds `TaskStatus.paused` which may require updating `switch` statements to remain exhaustive, though this status will never appear unless you use pause.
### Individual status and progress callbacks for batch upload and download
Adds status and progress callbacks for individual files in a batch. This is breaking if you used a batch progress callback earlier, as that is now a named parameter. Change:
```
final result = await FileDownloader().downloadBatch(tasks, (succeeded, failed) {
print('$succeeded files succeeded, $failed have failed');
print('Progress is ${(succeeded + failed) / tasks.length} %');
});
```
to
```
final result = await FileDownloader().downloadBatch(tasks, batchProgressCallback: (succeeded, failed) {
...
});
```
To also monitor status and progress for each file in the batch, add a `taskStatusCallback` (taking `Task` and `TaskStatus` as arguments) and/or a `taskProgressCallback (taking `Task` and a double as arguments).
### iOS minimum version from 11.0 to 13.0
To improve Swift code readability and maintenance, the minimum iOS version has moved from 11.0 to 13.0
## 4.2.3
Fixed another bug with `database.allRecords` if taskId contains illegal filename characters (like '/'). For
tracking record id purposes those are now replaced with '_'
## 4.2.2
Fixed bug with `database.allRecords` if taskId contains illegal filename characters (like '/'). For
tracking record id purposes those are now replaced with '_'
## 4.2.1
Upgraded dependency to address issue with Windows platform database performance
## 4.2.0
Added `creationTime` field to `Request` and `Task`.
Added `allRecordsOlderThan(Duration age, {String? group})` to `database`, making it easy to extract
the `TaskRecord` entries that are stale.
## 4.1.0
Adds optional tracking of task status and progress in a persistent database.
To keep track of the status and progress of all tasks, even after they have completed, activate tracking by calling `trackTasks()` and use the `database` field to query. For example:
```
// at app startup, start tracking
await FileDownloader().trackTasks();
// somewhere else: enqueue a download
final task = DownloadTask(
url: 'https://google.com',
filename: 'testfile.txt');
final successfullyEnqueued = await FileDownloader().enqueue(task);
// somewhere else: query the task status by getting a `TaskRecord`
// from the database
final record = await FileDownloader().database.recordForId(task.taskId);
print('Taskid ${record.taskId} with task ${record.task} has '
'status ${record.taskStatus} and progress ${record.progress}'
```
You can interact with the `database` using `allRecords`, `recordForId`, `deleteAllRecords`, `deleteRecordWithId` etc. Note that only tasks that you asked to be tracked (using `trackTasks`, which activates tracking for all tasks in a group) will be in the database. All active tasks in the queue, regardless of tracking, can be queried via the `FileDownloader.taskForId` call etc, but those will only return the task itself, not its status or progress, as those are expected to be monitored via listener or callback. Note: tasks that are started using `download`, `upload`, `batchDownload` or `batchUpload` are assigned a special group name 'await', as callbacks for these tasks are handled within the `FileDownloader`. If you want to track those tasks in the database, call `FileDownloader().trackTasks(FileDownloader.awaitGroup)` at the start of your app.
## 4.0.0
Adds support for MacOS, Windows and Linux and refactored the backend to be more easily extensible.
Changes FileDownloader usage from static to a singleton. This means that instead of calling
`FileDownloader.downloader(...)` now call `FileDownloader().downloader(...)` etc.
Calling `.initialize` is not longer required.
## 3.0.1
iOS BaseDirectory.applicationSupport now uses iOS applicationSupportDirectory instead of
libraryDirectory
## 3.0.0
Version 3 introduces uploads, `onProgress` and `onStatus` callbacks passed to `download` and `upload`,
and cleans up the API to be less verbose.
The class hierarchy is `Request` -> `Task` -> (`DownloadTask` | `UploadTask`), and several
methods and callbacks will return or expect a `Task` that may be a `DownloadTask` or `UploadTask`.
To align naming convention, several class and enum names have been changed:
- class BackgroundDownloadTask -> DownloadTask, and field progressUpdates -> updates
- enum DownloadTaskStatus -> TaskStatus
- enum DownloadProgressUpdates -> Updates (and enum value changes)
- class BackgroundDownloadEvent -> TaskUpdate
- class BackgroundDownloadStatusEvent -> TaskStatusUpdate
- class BackgroundDownloadProgressEvent -> TaskProgressUpdate
- typedef DownloadStatusCallback -> TaskStatusCallback
- typedef DownloadProgressCallback -> TaskProgressCallback
- class DownloadBatch -> Batch
- typedef BatchDownloadProgressCallback -> BatchProgressCallback
## 2.1.1
The url and urlQueryParameters passed to a `BackgroundDownloadTask` or `Request` must be encoded if necessary. For example, if the url or query parameters contain a space, it must be replaced with %20 per urlencoding
## 2.1.0
Changes:
- Added option to use a POST request: setting the `post` field to a String or UInt8List passes that data to the server using the POST method to obtain your file
- Added `request` method, taking a `Request` object (a superclass of `BackgroundDownloadTask`), for simple server requests, where you process the server response directly (i.e. not in a file).
- Refactored Android Kotlin code and made small improvement to the fix for [issue](https://github.com/781flyingdutchman/background_downloader/issues/6) with
Firebase plugin `onMethodCall` handler
## 2.0.1
Fix for [issue](https://github.com/781flyingdutchman/background_downloader/issues/6) with
Firebase plugin `onMethodCall` handler
## 2.0.0
Added option to automatically retry failed downloads. This is a breaking change, though for most
existing implementations no or very little change is required.
The main change is the addition of `enqueued` and `waitingToRetry` status to the
`DownloadTaskStatus` enum (and removal of `undefined`). As a result, when checking a
`DownloadStatusUpdate` (e.g. using a `switch` statement) you need to cover these new cases (and
for existing implementations can typically just ignore them). The progressUpdate equivalent of
`waitingToRetry` is a value of -4.0, but for existing implementations this will never be
emitted, as they won't have retries.
The second change is that a task now emits `enqueued` when enqueued, and `running` once the actual
download (on the native platform) starts. In existing applications this can generally be ignored,
but it allows for more precise status updates.
To use automatic retries, simply set the `retries` field of the `BackgroundDownloadTask` to an
integer between 0 and 10. A normal download (without the need for retries) will follow status
updates from `enqueued` -> `running` -> `complete` (or `notFound`). If `retries` has been set and
the task fails, the sequence will be `enqueued` -> `running` ->
`waitingToRetry` -> `enqueued` -> `running` -> `complete` (if the second try succeeds, or more
retries if needed).
## 1.6.1
Fix for [issue](https://github.com/781flyingdutchman/background_downloader/issues/6) with
Firebase plugin `onMethodCall` handler
## 1.6.0
Added option to set `requiresWiFi` on the `BackgroundDownloadTask`, which ensures the task won't
start downloading unless a WiFi network is available. By default `requiresWiFi` is false, and
downloads will use the cellular (or metered) network if WiFi is not available, which may incur cost.
## 1.5.0
Added `allTasks` method to get a list of running tasks. Use `allTaskIds` to get a list of taskIds
only.
## 1.4.2
Added note to README referring to an issue (
and [fix](https://github.com/firebase/flutterfire/issues/9689#issuecomment-1304491789)) where the
firebase plugin interferes with the downloader
## 1.4.1
Improved example app, updated documentation and fixed minor Android bug
## 1.4.0
Added `downloadBatch` method to enqueue and wait for completion of a batch of downloads
## 1.3.0
Added option to use an event listener instead of (or in addition to) callbacks
## 1.2.0
Added FileDownloader.download as a convenience method for simple downloads. This method's Future
completes only after the download has completed or failed, and can be used for simple downloads
where status and progress checking is not required.
## 1.1.0
Added headers and metaData fields to the BackgroundDownloadTask. Headers will be added to the
request, and metaData is ignored but may be helpful to the user
## 1.0.2
Replaced Ktor client with a basic Kotlin implementation
## 1.0.0
Initial release

View file

@ -0,0 +1,57 @@
# Configuration
The downloader can be configured by calling `FileDownloader().configure` before executing any downloads or uploads. Configurations can be set for Global, Android, iOS or Desktop separately, where only the `globalConfig` is applied to every platform, before the platform-specific configuration is applied. This can be used to 'override' the configuration for only one platform.
Configurations are platform-specific and support and behavior may change between platforms. At this moment, consider configuration experimental, and expect changes that will not be considered breaking (and will not trigger a major version increase).
A configuration can be a single config or a list of configs, and every config is a `Record` with the first element a `String` indicating what to configure (use the `Config.` variables to avoid typos), and the second element an argument (which itself can be a `Record` if more than one argument is needed).
The following configurations are supported:
* Timeouts
- `(Config.requestTimeout, Duration? duration)` sets the requestTimeout, or if null resets to default. This is the time allowed to connect with the server
- `(Config.resourceTimeout, Duration? duration)` sets the iOS resourceTimeout, or if null resets to default. This is the time allowed to complete the download/upload
* Checking available space
- `(Config.checkAvailableSpace, int minMegabytes)` ensures a file download fails if less than `minMegabytes` space will be available after this download completes
- `(Config.checkAvailableSpace, false)` or `(Config.checkAvailableSpace, Config.never)`turns off checking available space
* When to use the Android cache directory
- `(Config.useCacheDir, String whenToUse)` with values 'never', 'always' or 'whenAble'. Default is `Config.whenAble`, which will use the cacheDir if the size of the file to download is less than half the cacheQuota given to your app buy Android. If you find that your app fails to download large files or cannot resume from pause, set this to `Config.never` and make sure to clear up the directory aligned with `BaseDirectory.applicationSupport` for stray temp files. Temp file names start with `com.bbflight.background_downloader`. Note that the use of cache or applicationSupport directories responds to the configuration `Config.useExternalStorage`: if set, the external cache and applicationSupport directories will be used
* HTTP Proxy
- `(Config.proxy, (String address, int port))` sets the proxy to this address and port (note: address and port are contained in a record)
- `(Config.proxy, false)` removes the proxy
* Bypassing HTTPS (TLS) certificate validation
- `(Config.bypassTLSCertificateValidation, bool bypass)` bypasses TLS certificate validation for HTTPS connections. This is insecure, and can not be used in release mode. It is meant to make it easier to use a local server with a self-signed certificate during development only. It is only supported on Android and Desktop. On Android, to turn the bypass off, restart your app with this configuration removed.
* Android: run task in foreground (removes 9 minute timeout and may improve chances of task surviving background). Note that for a task to run in foreground it _must_ have a `running` notification configured, otherwise it will execute normally regardless of this setting
- `(Config.runInForeground, bool activate)` or `(Config.runInForeground, Config.always)` or `(Config.runInForeground, Config.never)` activates or de-activates foreground mode for all tasks
- `(Config.runInForegroundIfFileLargerThan, int fileSize)` activates foreground mode for downloads/uploads that exceed this file size, expressed in MB
* Android: use external storage. Either your app runs in default (internal storage) mode, or in external storage. You cannot switch between internal and external, as the directory structure that - for example - `BaseDirectory.applicationDocuments` refers to is different in each mode
- `(Config.useExternalStorage, String whenToUse)` with values 'never' or 'always'. Default is `Config.never`. See [here](#android-external-storage) for important details
* Localization
- `(Config.localize, Map<String, String> translation)` localizes the words 'Cancel', 'Pause' and 'Resume' as used in notifications, presented as a map (iOS only, see docs for Android notifications)
On Android and iOS, most configurations are stored in native 'shared preferences' to ensure that background tasks have access to the configuration. This means that configuration persists across application restarts, and this can lead to some surprising results. For example, if during testing you set a proxy and then remove that configuration line, the proxy configuration is not removed from persistent storage on your test device. You need to explicitly set `('proxy', false)` to remove the stored configuration on that device.
A configuration can be called multiple times, and affects all tasks *running* after the configuration call. Tasks enqueued _before_ a call, that run _after_ the call (e.g. because they are waiting for other downloads to complete) will run under the newly set configuration, not the one that was active when they were enqueued. On iOS, configuration of requestTimeout, resourceTimeout and proxy can only be set once, before the first task is executed
# Android external storage
Android has a complex storage model, see [here](https://developer.android.com/training/data-storage), that allows you to store App-Specific files in internal or external storage, and also offers Shared Storage. For Shared Storage you can use `FileDownloader().moveToSharedStorage` - this does not require configuration, and won't be covered here.
For App-specific storage you can configure the downloader to use either internal storage (the default) or external storage. Unlike other configurations, you cannot switch, so you have to choose which mode your app will use.
The configuration affects the path to the directories described by the `BaseDirectory` enum. For internal storage, the paths will look like this:
* BaseDirectory.applicationDocuments -> path is /data/user/0/com.bbflight.background_downloader_example/app_flutter/google.html
* BaseDirectory.applicationSupport -> path is /data/user/0/com.bbflight.background_downloader_example/files/google.html
* BaseDirectory.applicationLibrary -> path is /data/user/0/com.bbflight.background_downloader_example/files/Library/google.html
* BaseDirectory.temporary -> path is /data/user/0/com.bbflight.background_downloader_example/cache/google.html
Despite the somewhat strange `app_flutter` subdirectory, these paths line up with the directories you will get when using the `path_provider` package.
When configuring the downloader to use external storage, those same `BaseDirectory` entries will create a path like this:
* BaseDirectory.applicationDocuments -> path is /storage/emulated/0/Android/data/com.bbflight.background_downloader_example/files/google.html
* BaseDirectory.applicationSupport -> path is /storage/emulated/0/Android/data/com.bbflight.background_downloader_example/files/Support/google.html
* BaseDirectory.applicationLibrary -> path is /storage/emulated/0/Android/data/com.bbflight.background_downloader_example/files/Library/google.html
* BaseDirectory.temporary -> path is /storage/emulated/0/Android/data/com.bbflight.background_downloader_example/cache/google.html
Note the external `files` or `cache` directory is now a base, with subdirectories for 'Support' and 'Library'. Calls to `task.filePath` will return the correct path to these external directories - they cannot easily be constructed otherwise.
Never store absolute paths to these files, as the actual location may differ. Also note that using external storage can lead to errors, as the storage may not be available at the time it is requested. Therefore, `task.filePath` may throw a `FileSystemException` if using external storage.

View file

@ -0,0 +1,37 @@
Copyright 2022 BBFlight LLC
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
---
This software includes a modified version of package localstore (https://pub.dev/packages/localstore/versions/1.3.5). The license for that software is:
MIT License
Copyright (c) 2021 Hanoi University of Mining and Geology, Vietnam.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,59 @@
# Migration
Two types of migration to discuss:
1. Migration from Flutter Downloader
2. Migration of the persistent storage used in background_downloader
## Migration from Flutter Downloader
Key differences with Flutter Downloader:
* Every download or upload task is an object (e.g. `DownloadTask`) that defines how the task needs to be executed. The object is passed back to you when you receive status and progress updates, and is the key object in the downloader
* When you define a task, a few things are quite different:
- You set your filepath **not** as an absolute path, but as a combination of a `baseDirectory` (an enum), a `subdirectory` (a String) and a `filename` (a String)
- If you want the filename to be provided by the server, call `withSuggestedFilename` on the task you define, before starting the download. Alternatively, set the `filename` field of the task to `DownloadTask.suggestedFilename`, but you'll have to get the _actual_ filename from the task provided to you in status or progress updates. Task fields are final, so the task where you set the filename to `DownloadTask.suggestedFilename` will never change.
- By default, a task only generates status updates, not progress updates. If you want both, set the task's `updates` field to `Updates.statusAndProgress`
- You can only download to an app-specific directory (one of the `BaseDirectory` values). _After_ download completes, you can move a file to shared storage, such as the Android Downloads directory, using `moveToSharedStorage`
* Callbacks _can_ be registered, just like with Flutter Downloader, but prefer using `FileDownloader().updates.listen` and listen to updates in a more Flutter-like way. Note that you should only listen to this updates stream once, so you need to create a singleton object (or BLOC) where this happens. If you try to listen only on certain pages/screens in your app you will miss updates, leading to inconsistent state
* You can `enqueue` a task, like with Flutter Downloader, but you can also call `result = await FileDownloader().download(task)` and wait for a download to complete with a result
* Notifications work very differently. You have to configure notifications (for all tasks, a group, or one specific task) before calling `enqueue` or `download`
* By default, there is no database where task status is maintained, as you get status updates along the way. If you do want to track tasks, call `trackTasks`, and use the `database` field to query the database. There are different options for the backing database, and migration from the Flutter Downloader database is partially supported, see [below](#sqlite-persistent-storage).
* On Android, you can choose to use external storage instead of the default internal storage. This is a configuration, and can only be set at the application level, so you either use internal or external storage for all downloads and uploads. See the [configuration document](https://github.com/781flyingdutchman/background_downloader/blob/main/CONFIG.md) for important details and limitations.
*
## Migration of the persistent storage used in background_downloader
The downloader uses persistent storage to store things like paused tasks and resume data. If you invoke `trackTasks`, then `TaskRecord` objects are also stored in this persistent storage. The `database` object serves as an interface to this persistent storage, but you can change the type of persistent storage that 'backs' the `database`.
The default persistent storage is a modified version of the Localstore package - a simple filesystem based storage solution that is available on all platforms.
You can use a different persistent storage by passing an alternative (that implements the `PersistentStorage` interface) as the `persistentStorage` argument to your very first call to `FileDownloader`.
### SQLite persistent storage
One such alternative, `SqlitePersistentStorage`, is included in the package (and adds a dependency to the sqflite package). This storage supports migrations from Localstore and from the Flutter Downloader SQLite database. To activate migration, pass the desired migrations to the constructor of the `SqlitePersistentStorage`, then pass the object to the `FileDownloader`:
```agsl
final sqlStorage = SqlitePersistentStorage(migrationOptions: ['local_store', 'flutter_downloader']);
FileDownloader(persistentStorage: sqlStorage);
// start using the FileDownloader
```
When used this way, the downloader will attempt to migrate data from either Localstore or Flutter Downloader to the new SQLite database, when it is created.
Only Flutter Downloader entries that are `complete`, `failed` or `canceled` will be migrated to the background downloader, and only the fields `taskId`, `url`, `filename`, `headers` and `time_created` migrate. We attempt to reconstruct the file destination (stored in `savedDir`), provided it points to an app-specific location. If the location is external (e.g. Downloads) then the record will be skipped and not migrated. The migration is experimental, so please test thoroughly before relying on this for your existing app.
The SQLite database has an additional method `retrieveTaskRecords` that takes SQL-like `where` and `whereArgs` arguments, allowing you to query the SQLite database with TaskRecords directly. Supported columns:
* taskId
* url
* filename
* group
* metaData
* creationTime (as an integer representing _seconds_ since the epoch - not milliseconds)
* status (as an integer, the index into the `TaskStatus` enum)
* progress
You only use these fields to query - the returned value is a list of `TaskRecord` objects. The `retrieveTaskRecords` method is _only_ available on the `SqlitePersistentStorage` object that you created and passed to the `FileDownloader`. It is not part of the `FileDownloader().database` functionality (because not all backing databases allow a query like this), and you should continue to use the `database` object wherever possible, to ensure compatibility with future upgrades. Future changes to `SqlitePersistentStorage` may not be considered breaking changes, as they do not affect the default.
### Other storage and migration
If you already have an SQLite database that you use to keep track of things, you may want to use that to also store the downloader's data. You can extend `SqlitePersistentStorage` or implement the `PersistentStorage` interface in the class you already have. Likewise, if you want to implement storage in Hive or some other solution, make sure to implement `PersistentStorage` and perhaps share your solution with the community!
If you want to add migration capability to your own `PersistentStorage` class, then use the `PersistentStorageMigrator` and/or extend that with functionality beyond Localstore and Flutter Downloader. The documentation shows you how this can be done.

View file

@ -0,0 +1,815 @@
# A background file downloader and uploader for iOS, Android, MacOS, Windows and Linux
---
**NOTE**
This version requires Dart 3. If you need support for Dart 2 please use version `^6.1.1`, which will be maintained until the end of 2023.
If you want a 'lite' version, without references to the `sqflite` package, use branch [V7-lite](https://github.com/781flyingdutchman/background_downloader/tree/V7-lite) of the repo.
---
**If you are migrating from Flutter Downloader, please read the [migration document](https://github.com/781flyingdutchman/background_downloader/blob/main/MIGRATION.md)**
---
Create a [DownloadTask](https://pub.dev/documentation/background_downloader/latest/background_downloader/DownloadTask-class.html) to define where to get your file from, where to store it, and how you want to monitor the download, then call `FileDownloader().download` and wait for the result. Background_downloader uses URLSessions on iOS and DownloadWorker on Android, so tasks will complete also when your app is in the background. The download behavior is highly consistent across all supported platforms: iOS, Android, MacOS, Windows and Linux.
Monitor progress by passing an `onProgress` listener, and monitor detailed status updates by passing an `onStatus` listener to the `download` call. Alternatively, monitor tasks centrally using an [event listener](#using-an-event-listener) or [callbacks](#using-callbacks) and call `enqueue` to start the task.
Optionally, keep track of task status and progress in a persistent [database](#using-the-database-to-track-tasks), and show mobile [notifications](#notifications) to keep the user informed and in control when your app is in the background.
To upload a file, create an [UploadTask](https://pub.dev/documentation/background_downloader/latest/background_downloader/UploadTask-class.html) and call `upload`. To make a regular [server request](#server-requests), create a [Request](https://pub.dev/documentation/background_downloader/latest/background_downloader/Request-class.html) and call `request`. To download in parallel from multiple servers, create a [ParallelDownloadTask](https://pub.dev/documentation/background_downloader/latest/background_downloader/ParallelDownloadTask-class.html).
The plugin supports [headers](#headers), [retries](#retries), [priority](#priority), [requiring WiFi](#requiring-wifi) before starting the up/download, user-defined [metadata and display name](#metadata-and-displayname) and GET, [POST](#post-requests) and other http(s) [requests](#http-request-method), and can be [configured](#configuration) by platform. You can [manage the tasks in the queue](#managing-tasks-and-the-queue) (e.g. cancel, pause and resume), and have different handlers for updates by [group](#grouping-tasks) of tasks. Downloaded files can be moved to [shared storage](#shared-and-scoped-storage) to make them available outside the app.
No setup is required for [Android](#android) (except when using notifications), Windows and Linux, and only minimal [setup for iOS](#ios) and [MacOS](#macos).
## Usage examples
### Downloads example
```dart
// Use .download to start a download and wait for it to complete
// define the download task (subset of parameters shown)
final task = DownloadTask(
url: 'https://google.com/search',
urlQueryParameters: {'q': 'pizza'},
filename: 'results.html',
headers: {'myHeader': 'value'},
directory: 'my_sub_directory',
updates: Updates.statusAndProgress, // request status and progress updates
requiresWiFi: true,
retries: 5,
allowPause: true,
metaData: 'data for me');
// Start download, and wait for result. Show progress and status changes
// while downloading
final result = await FileDownloader().download(task,
onProgress: (progress) => print('Progress: ${progress * 100}%'),
onStatus: (status) => print('Status: $status')
);
// Act on the result
switch (result.status) {
case TaskStatus.complete:
print('Success!');
case TaskStatus.canceled:
print('Download was canceled');
case TaskStatus.paused:
print('Download was paused');
default:
print('Download not successful');
}
```
### Enqueue example
```dart
// Use .enqueue for true parallel downloads, i.e. you don't wait for completion of the tasks you
// enqueue, and can enqueue hundreds of tasks simultaneously.
// First define an event listener to process `TaskUpdate` events sent to you by the downloader,
// typically in your app's `initState()`:
FileDownloader().updates.listen((update) {
switch (update) {
case TaskStatusUpdate _:
// process the TaskStatusUpdate, e.g.
switch (update.status) {
case TaskStatus.complete:
print('Task ${update.task.taskId} success!');
case TaskStatus.canceled:
print('Download was canceled');
case TaskStatus.paused:
print('Download was paused');
default:
print('Download not successful');
}
case TaskProgressUpdate _:
// process the TaskProgressUpdate, e.g.
progressUpdateStream.add(update); // pass on to widget for indicator
}
});
// Next, enqueue tasks to kick off background downloads, e.g.
final successfullyEnqueued = await FileDownloader().enqueue(DownloadTask(
url: 'https://google.com',
filename: 'google.html',
updates: Updates.statusAndProgress));
```
### Uploads example
```dart
/// define the multi-part upload task (subset of parameters shown)
final task = UploadTask(
url: 'https://myserver.com/uploads',
filename: 'myData.txt',
fields: {'datafield': 'value'},
fileField: 'myFile',
updates: Updates.statusAndProgress // request status and progress updates
);
// Start upload, and wait for result. Show progress and status changes
// while uploading
final result = await FileDownloader().upload(task,
onProgress: (progress) => print('Progress: ${progress * 100}%'),
onStatus: (status) => print('Status: $status')
);
// Act on result, similar to download
```
### Batch download example
```dart
final tasks = [task1, task2, task3]; // a list of Download tasks
// download the batch
final result = await FileDownloader().downloadBatch(tasks,
batchProgressCallback: (succeeded, failed) =>
print('Completed ${succeeded + failed} out of ${tasks.length}, $failed failed')
);
```
### Task tracking database example
```dart
// activate tracking at the start of your app
await FileDownloader().trackTasks();
// somewhere else: enqueue a download (does not complete immediately)
final task = DownloadTask(
url: 'https://google.com',
filename: 'testfile.txt');
final successfullyEnqueued = await FileDownloader().enqueue(task);
// query the tracking database, returning a record for each task
final records = await FileDownloader().database.allRecords();
for (record in records) {
print('Task ${record.tasksId} status is ${record.status}');
if (record.status == TaskStatus.running) {
print('-- progress ${record.progress * 100}%');
print('-- file size ${record.expectedFileSize} bytes');
}
};
// or get record for specific task
final record = await FileDownloader().database.recordForId(task.taskId);
```
### Notifications example
```dart
// configure notification for all tasks
FileDownloader().configureNotification(
running: TaskNotification('Downloading', 'file: {filename}'),
complete: TaskNotification('Download finished', 'file: {filename}'),
progressBar: true
);
// all downloads will now show a notification while downloading, and when complete.
// {filename} will be replaced with the task's filename.
```
---
# Contents
- [Basic use](#basic-use)
- [Tasks and the FileDownloader](#tasks-and-the-filedownloader)
- [Monitoring the task](#monitoring-the-task)
- [Specifying the location of the file to download or upload](#specifying-the-location-of-the-file-to-download-or-upload)
- [A batch of files](#a-batch-of-files)
- [Central monitoring and tracking in a persistent database](#central-monitoring-and-tracking-in-a-persistent-database)
- [Using an event listener](#using-an-event-listener)
- [Using callbacks](#using-callbacks)
- [Using the database to track Tasks](#using-the-database-to-track-tasks)
- [Notifications](#notifications)
- [Shared and scoped storage](#shared-and-scoped-storage)
- [Uploads](#uploads)
- [Parallel downloads](#parallel-downloads)
- [Managing tasks in the queue](#managing-tasks-and-the-queue)
- [Canceling, pausing and resuming tasks](#canceling-pausing-and-resuming-tasks)
- [Grouping tasks](#grouping-tasks)
- [Task queues](#task-queues)
- [Server requests](#server-requests)
- [Optional parameters](#optional-parameters)
- [Initial setup](#initial-setup)
- [Configuration](#configuration)
- [Limitations](#limitations)
## Basic use
### Tasks and the FileDownloader
A `DownloadTask` or `UploadTask` (both subclasses of `Task`) defines one download or upload. It contains the `url`, the file name and location, what updates you want to receive while the task is in progress, [etc](#optional-parameters). The [FileDownloader](https://pub.dev/documentation/background_downloader/latest/background_downloader/FileDownloader-class.html) class is the entrypoint for all calls. To download a file:
```dart
final task = DownloadTask(
url: 'https://google.com',
filename: 'testfile.txt'); // define your task
final result = await FileDownloader().download(task); // do the download and wait for result
```
The `result` will be a [TaskStatusUpdate](https://pub.dev/documentation/background_downloader/latest/background_downloader/TaskStatusUpdate-class.html), which has a field `status` that indicates how the download ended: `.complete`, `.failed`, `.canceled` or `.notFound`. If the `status` is `.failed`, the `result.exception` field will contain a `TaskException` with information about what went wrong. For uploads and some unsuccessful downloads, the `responseBody` will contain the server response.
### Monitoring the task
#### Progress
If you want to monitor progress during the download itself (e.g. for a large file), then add a progress callback that takes a double as its argument:
```dart
final result = await FileDownloader().download(task,
onProgress: (progress) => print('Progress update: $progress'));
```
Progress updates start with 0.0 when the actual download starts (which may be in the future, e.g. if waiting for a WiFi connection), and will be sent periodically, not more than twice per second per task. If a task completes successfully you will receive a final progress update with a `progress` value of 1.0 (`progressComplete`). Failed tasks generate `progress` of `progressFailed` (-1.0), canceled tasks `progressCanceled` (-2.0), notFound tasks `progressNotFound` (-3.0), waitingToRetry tasks `progressWaitingToRetry` (-4.0) and paused tasks `progressPaused` (-5.0).
Use `await task.expectedFileSize()` to query the server for the size of the file you are about
to download. The expected file size is also included in `TaskProgressUpdate`s that are sent to
listeners and callbacks - see [Using an event listener](#using-an-event-listener) and [Using callbacks](#using-callbacks)
A [DownloadProgressIndicator](https://pub.dev/documentation/background_downloader/latest/background_downloader/DownloadProgressIndicator-class.html) widget is included with the package, and the example app shows how to wire it up.
The widget can be configured to include pause and resume buttons, and to expand to show multiple
simultaneous downloads, or to collapse and show a file download counter.
To provide progress updates (as a percentage of total file size) the downloader needs to know the size of the file when starting the download. Most servers provide this in the "Content-Length" header of their response. If the server does not provide the file size, yet you know the file size (e.g. because you have stored the file on the server yourself), then you can let the downloader know by providing a `{'Range': 'bytes=0-999'}` or a `{'Known-Content-Length': '1000'}` header to the task's `header` field. Both examples are for a content length of 1000 bytes. The downloader will assume this content length when calculating progress.
#### Status
If you want to monitor status changes while the download is underway (i.e. not only the final state, which you will receive as the result of the `download` call) you can add a status change callback that takes the status as an argument:
```dart
final result = await FileDownloader().download(task,
onStatus: (status) => print('Status update: $status'));
```
The status will follow a sequence of `.enqueued` (waiting to execute), `.running` (actively
downloading) and then one of the final states mentioned before, or `.waitingToRetry` if retries
are enabled and the task failed.
If a task fails with `TaskStatus.failed` then in some cases it is possible to `resume` the task without having to start from scratch. You can test whether this is possible by calling `FileDownloader().taskCanResume(task)` and if true, call `resume` instead of `download` or `enqueue`.
#### Elapsed time
If you want to keep an eye on how long the download is taking (e.g. to warn the user that there may be an issue with their network connection, or to cancel the task if it takes too long), pass an `onElapsedTime` callback to the `download` method. The callback takes a single argument of type `Duration`, representing the time elapsed since the call to `download` was made. It is called at regular intervals (defined by `elapsedTimeInterval` which defaults to 5 seconds), so you can react in different ways depending on the total time elapsed. For example:
```dart
final result = await FileDownloader().download(
task,
onElapsedTime: (elapsed) {
print('This is taking rather long: $elapsed');
},
elapsedTimeInterval: const Duration(seconds: 30));
```
The elapsed time logic is only available for `download`, `upload`, `downloadBatch` and `uploadBatch`. It is not available for tasks started using `enqueue`, as there is no expectation that those complete imminently.
### Specifying the location of the file to download or upload
In the `DownloadTask` and `UploadTask` objects, the `filename` of the task refers to the filename without directory. To store the task in a specific directory, add the `directory` parameter to the task. That directory is relative to the base directory, so cannot start with a `/`. By default, the base directory is the directory returned by the call to `getApplicationDocumentsDirectory()` of the [path_provider](https://pub.dev/packages/path_provider) package, but this can be changed by also passing a `baseDirectory` parameter (`BaseDirectory.temporary` for the directory returned by `getTemporaryDirectory()`, `BaseDirectory.applicationSupport` for the directory returned by `getApplicationSupportDirectory()` and `BaseDirectory.applicationLibrary` for the directory returned by `getLibraryDirectory()` on iOS and MacOS, or subdir 'Library' of the directory returned by `getApplicationSupportDirectory()` on other platforms).
So, to store a file named 'testfile.txt' in the documents directory, subdirectory 'my/subdir', define the task as follows:
```dart
final task = DownloadTask(
url: 'https://google.com',
filename: 'testfile.txt',
directory: 'my/subdir');
```
To store that file in the temporary directory:
```dart
final task = DownloadTask(
url: 'https://google.com',
filename: 'testfile.txt',
directory: 'my/subdir',
baseDirectory: BaseDirectory.temporary);
```
The downloader will only store the file upon success (so there will be no partial files saved), and if so, the destination is overwritten if it already exists, and all intermediate directories will be created if needed.
Note: the reason you cannot simply pass a full absolute directory path to the downloader is that the location of the app's documents directory may change between application starts (on iOS), and may therefore fail for downloads that complete while the app is suspended. You should therefore never store permanently, or hard-code, an absolute path.
Android has two storage modes: internal (default) and external storage. Read the [configuration document](https://github.com/781flyingdutchman/background_downloader/blob/main/CONFIG.md) for details on how to configure your app to use external storage instead of the default.
#### Server-suggested filename
If you want the filename to be provided by the server (instead of assigning a value to `filename` yourself), you have two options. The first is to create a `DownloadTask` that pings the server to determine the suggested filename:
```dart
final task = await DownloadTask(url: 'https://google.com')
.withSuggestedFilename(unique: true);
```
The method `withSuggestedFilename` returns a copy of the task it is called on, with the `filename` field modified based on the filename suggested by the server, or the last path segment of the URL, or unchanged if neither is feasible (e.g. due to a lack of connection). If `unique` is true, the filename will be modified such that it does not conflict with an existing filename by adding a sequence. For example "file.txt" would become "file (1).txt". You can also supply a `taskWithFilenameBuilder` to suggest the filename yourself, based on response headers.
The second approach is to set the `filename` field of the `DownloadTask` to `DownloadTask.suggestedFilename`, to indicate that you would like the server to suggest the name. In this case, you will receive the name via the task's status and/or progress updates, so you have to be careful _not_ to use the original task's filename, as that will still be `DownloadTask.suggestedFilename`. For example:
```dart
final task = await DownloadTask(url: 'https://google.com', filename: DownloadTask.suggestedFilename);
final result = await FileDownloader().download(task);
print('Suggested filename=${result.task.filename}'); // note we don't use 'task', but 'result.task'
print('Wrong use filename=${task.filename}'); // this will print '?' as 'task' hasn't changed
```
### A batch of files
To download a batch of files and wait for completion of all, create a `List` of `DownloadTask` objects and call `downloadBatch`:
```dart
final result = await FileDownloader().downloadBatch(tasks);
```
The result is a `Batch` object that contains the result for each task in `.results`. You can use `.numSucceeded` and `.numFailed` to check if all files in the batch downloaded successfully, and use `.succeeded` or `.failed` to iterate over successful or failed tasks within the batch. If you want to get progress updates for the batch (in terms of how many files have been downloaded) then add a callback:
```dart
final result = await FileDownloader().downloadBatch(tasks, batchProgressCallback: (succeeded, failed) {
print('$succeeded files succeeded, $failed have failed');
print('Progress is ${(succeeded + failed) / tasks.length} %');
});
```
The callback will be called upon completion of each task (whether successful or not), and will start with (0, 0) before any downloads start, so you can use that to start a progress indicator.
To also monitor status and progress for each file in the batch, add a [TaskStatusCallback](https://pub.dev/documentation/background_downloader/latest/background_downloader/TaskStatusCallback.html) and/or a [TaskProgressCallback](https://pub.dev/documentation/background_downloader/latest/background_downloader/TaskProgressCallback.html)
To monitor based on elapsed time, see [Elapsed time](#elapsed-time).
For uploads, create a `List` of `UploadTask` objects and call `uploadBatch` - everything else is the same.
## Central monitoring and tracking in a persistent database
Instead of monitoring in the `download` call, you may want to use a centralized task monitoring approach, and/or keep track of tasks in a database. This is helpful for instance if:
1. You start download in multiple locations in your app, but want to monitor those in one place, instead of defining `onStatus` and `onProgress` for every call to `download`
2. You have different groups of tasks, and each group needs a different monitor
3. You want to keep track of the status and progress of tasks in a persistent database that you query
4. Your downloads take long, and your user may switch away from your app for a long time, which causes your app to get suspended by the operating system. A download started with a call to `download` will continue in the background and will finish eventually, but when your app restarts from a suspended state, the result `Future` that you were awaiting when you called `download` may no longer be 'alive', and you will therefore miss the completion of the downloads that happened while suspended. This situation is uncommon, as the app will typically remain alive for several minutes even when moving to the background, but if you find this to be a problem for your use case, then you should process status and progress updates for long running background tasks centrally.
Central monitoring can be done by listening to an updates stream, or by registering callbacks. In both cases you now use `enqueue` instead of `download` or `upload`. `enqueue` returns almost immediately with a `bool` to indicate if the `Task` was successfully enqueued. Monitor status changes and act when a `Task` completes via the listener or callback.
To ensure your callbacks or listener capture events that may have happened when your app was suspended in the background, call `resumeFromBackground` right after registering your callbacks or listener.
In summary, to track your tasks persistently, follow these steps in order, immediately after app startup:
1. Register an event listener or callback(s) to process status and progress updates
2. call `await FileDownloader().trackTasks()` if you want to track the tasks in a persistent database
3. call `await FileDownloader().resumeFromBackground()` to ensure events that happened while your app was in the background are processed
The rest of this section details [event listeners](#using-an-event-listener), [callbacks](#using-callbacks) and the [database](#using-the-database-to-track-tasks) in detail.
### Using an event listener
Listen to updates from the downloader by listening to the `updates` stream, and process those updates centrally. For example, the following creates a listener to monitor status and progress updates for downloads, and then enqueues a task as an example:
```dart
final subscription = FileDownloader().updates.listen((update) {
if (update is TaskStatusUpdate) {
print('Status update for ${update.task} with status ${update.status}');
} else if (update is TaskProgressUpdate) {
print('Progress update for ${update.task} with progress ${update.progress}');
}
});
// define the task
final task = DownloadTask(
url: 'https://google.com',
filename: 'google.html',
updates: Updates.statusAndProgress); // needed to also get progress updates
// enqueue the download
final successFullyEnqueued = await FileDownloader().enqueue(task);
// updates will be sent to your subscription listener
```
A TaskProgressUpdate includes `expectedFileSize`, `networkSpeed` and `timeRemaining`. Check the associated `hasExpectedFileSize`, `hasNetworkSpeed` and `hasTimeRemaining` before using the values in these fields. Use `networkSpeedAsString` and `timeRemainingAsString` for human readable versions of these values.
Note that `successFullyEnqueued` only refers to the enqueueing of the download task, not its result, which must be monitored via the listener. Also note that in order to get progress updates the task must set its `updates` field to a value that includes progress updates. In the example, we are asking for both status and progress updates, but other combinations are possible. For example, if you set `updates` to `Updates.status` then the task will only generate status updates and no progress updates. You define what updates to receive on a task by task basis via the `Task.updates` field, which defaults to status updates only.
You can start your subscription in a convenient place, like a widget's `initState`, and don't forget to cancel your subscription to the stream using `subscription.cancel()`. Note the stream can only be listened to once, though you can reset the stream controller by calling `await FileDownloader().resetUpdates()` to start listening again.
### Using callbacks
Instead of listening to the `updates` stream you can register a callback for status updates, and/or a callback for progress updates. This may be the easiest way if you want different callbacks for different [groups](#grouping-tasks).
The [TaskStatusCallback](https://pub.dev/documentation/background_downloader/latest/background_downloader/TaskStatusCallback.html) receives a [TaskStatusUpdate](https://pub.dev/documentation/background_downloader/latest/background_downloader/TaskStatusUpdate-class.html), so a simple callback function is:
```dart
void taskStatusCallback(TaskStatusUpdate update) {
print('taskStatusCallback for ${update.task) with status ${update.status} and exception ${update.exception}');
}
```
The [TaskProgressCallback](https://pub.dev/documentation/background_downloader/latest/background_downloader/TaskProgressCallback.html) receives a [TaskProgressUpdate](https://pub.dev/documentation/background_downloader/latest/background_downloader/TaskProgressUpdate-class.html), so a simple callback function is:
```dart
void taskProgressCallback(TaskProgressUpdate update) {
print('taskProgressCallback for ${update.task} with progress ${update.progress} '
'and expected file size ${update.expectedFileSize}');
}
```
A basic file download with just status monitoring (no progress) then requires registering the central callback, and a call to `enqueue` to start the download:
```dart
FileDownloader().registerCallbacks(taskStatusCallback: taskStatusCallback);
final successFullyEnqueued = await FileDownloader().enqueue(
DownloadTask(url: 'https://google.com', filename: 'google.html'));
```
You define what updates to receive on a task by task basis via the `Task.updates` field, which defaults to status updates only. If you register a callback for a type of task, updates are provided only through that callback and will not be posted on the `updates` stream.
Note that all tasks will call the same callback, unless you register separate callbacks for different [groups](#grouping-tasks) and set your `Task.group` field accordingly.
You can unregister callbacks using `FileDownloader().unregisterCallbacks()`.
### Using the database to track Tasks
To keep track of the status and progress of all tasks, even after they have completed, activate tracking by calling `trackTasks()` and use the `database` field to query and retrieve the [TaskRecord](https://pub.dev/documentation/background_downloader/latest/background_downloader/TaskRecord-class.html) entries stored. For example:
```dart
// at app startup, after registering listener or callback, start tracking
await FileDownloader().trackTasks();
// somewhere else: enqueue a download
final task = DownloadTask(
url: 'https://google.com',
filename: 'testfile.txt');
final successfullyEnqueued = await FileDownloader().enqueue(task);
// somewhere else: query the task status by getting a `TaskRecord`
// from the database
final record = await FileDownloader().database.recordForId(task.taskId);
print('Taskid ${record.taskId} with task ${record.task} has '
'status ${record.status} and progress ${record.progress} '
'with an expected file size of ${record.expectedFileSize} bytes'
```
You can interact with the `database` using `allRecords`, `allRecordsOlderThan`, `recordForId`,`deleteAllRecords`,
`deleteRecordWithId` etc. If you only want to track tasks in a specific [group](#grouping-tasks), call `trackTasksInGroup` instead.
By default, the downloader uses a modified version of the [localstore](https://pub.dev/packages/localstore) package to store the `TaskRecord` and other objects. To use a different persistent storage solution, create a class that implements the [PersistentStorage](https://pub.dev/documentation/background_downloader/latest/background_downloader/PersistentStorage-class.html) interface, and initialize the downloader by calling `FileDownloader(persistentStorage: yourStorageClass())` as the first use of the `FileDownloader`.
As an alternative to LocalStore, use `SqlitePersistentStorage` and see the [migration document](https://github.com/781flyingdutchman/background_downloader/blob/main/MIGRATION.md) to understand how it can migrate files from Localstore and the Flutter Downloader package.
## Notifications
On iOS and Android, for downloads only, the downloader can generate notifications to keep the user informed of progress also when the app is in the background, and allow pause/resume and cancellation of an ongoing download from those notifications.
Configure notifications by calling `FileDownloader().configureNotification` and supply a
`TaskNotification` object for different states. For example, the following configures
notifications to show only when actively running (i.e. download in progress), disappearing when
the download completes or ends with an error. It will also show a progress bar and a 'cancel'
button, and will substitute {filename} with the actual filename of the file being downloaded.
```dart
FileDownloader().configureNotification(
running: TaskNotification('Downloading', 'file: {filename}'),
progressBar: true);
```
To also show a notifications for other states, add a `TaskNotification` for `complete`, `error`
and/or `paused`. If `paused` is configured and the task can be paused, a 'Pause' button will
show for the `running` notification, next to the 'Cancel' button. To open the downloaded file
when the user taps the `complete` notification, add `tapOpensFile: true` to your call to
`configureNotification`
There are four possible substitutions of the text in the `title` or `body` of a `TaskNotification`:
* {filename} is replaced with the `filename` field of the `Task`
* {displayName} is replaced with the `displayName` field of the `Task`
* {progress} is substituted by a progress percentage, or '--%' if progress is unknown
* {metadata} is substituted by the `metaData` field of the `Task`
Notifications on iOS follow Apple's [guidelines](https://developer.apple.com/design/human-interface-guidelines/components/system-experiences/notifications/), notably:
* No progress bar is shown, and the {progress} substitution always substitutes to an empty string. In other words: only a single `running` notification is shown and it is not updated until the download state changes
* When the app is in the foreground, on iOS 14 and above the notification will not be shown but will appear in the NotificationCenter. On older iOS versions the notification will be shown also in the foreground. Apple suggests showing progress and download controls within the app when it is in the foreground
While notifications are possible on desktop platforms, there is no true background mode, and progress updates and indicators can be shown within the app. Notifications are therefore ignored on desktop platforms.
The `configureNotification` call configures notification behavior for all download tasks. You can specify a separate configuration for a `group` of tasks by calling `configureNotificationForGroup` and for a single task by calling `configureNotificationForTask`. A `Task` configuration overrides a `group` configuration, which overrides the default configuration.
When attempting to show its first notification, the downloader will ask the user for permission to show notifications (platform version dependent) and abide by the user choice. For Android, starting with API 33, you need to add `<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />` to your app's `AndroidManifest.xml`. Also on Android you can localize the button text by overriding string resources `bg_downloader_cancel`, `bg_downloader_pause`, `bg_downloader_resume` and descriptions `bg_downloader_notification_channel_name`, `bg_downloader_notification_channel_description`. Localization on iOS can be done through [configuration](#configuration).
To respond to the user tapping a notification, register a callback that takes `Task` and `NotificationType` as parameters:
```dart
FileDownloader().registerCallbacks(
taskNotificationTapCallback: myNotificationTapCallback);
void myNotificationTapCallback(Task task, NotificationType notificationType) {
print('Tapped notification $notificationType for taskId ${task.taskId}');
}
```
Note that convenience methods that `await` a result, such as `download` (but not `enqueue`), use the default `taskNotificationTapCallback` you register, even though those tasks are in the `awaitGroup`, because that behavior is more in line with expectations. If you need a separate callback for the `awaitGroup`, then set it _after_ setting the default callback. You set the default callback by omitting the `group` parameter in the `registerCallbacks` call.
### Opening a downloaded file
To open a file (e.g. in response to the user tapping a notification), call `FileDownloader().openFile` and supply either a `Task` or a full `filePath` (but not both) and optionally a `mimeType` to assist the Platform in choosing the right application to use to open the file.
The file opening behavior is platform dependent, and while you should check the return value of the call to `openFile`, error checking is not fully consistent.
Note that on Android, files stored in the `BaseDirectory.applicationDocuments` cannot be opened. You need to download to a different base directory (e.g. `.applicationSupport`) or move the file to shared storage before attempting to open it.
If all you want to do on notification tap is to open the file, you can simplify the process by
adding `tapOpensFile: true` to your call to `configureNotifications`, and you don't need to
register a `taskNotificationTapCallback`.
### Setup for notifications
On iOS, add the following to your `AppDelegate.swift`:
```swift
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
```
or if using Objective C, add to `AppDelegate.m`:
```objective-c
[UNUserNotificationCenter currentNotificationCenter].delegate = (id<UNUserNotificationCenterDelegate>) self;
```
## Shared and scoped storage
The download directories specified in the `BaseDirectory` enum are all local to the app. To make downloaded files available to the user outside of the app, or to other apps, they need to be moved to shared or scoped storage, and this is platform dependent behavior. For example, to move the downloaded file associated with a `DownloadTask` to a shared 'Downloads' storage destination, execute the following _after_ the download has completed:
```dart
final newFilepath = await FileDownloader().moveToSharedStorage(task, SharedStorage.downloads);
if (newFilePath == null) {
// handle error
} else {
// do something with the newFilePath
}
```
Because the behavior is very platform-specific, not all `SharedStorage` destinations have the same result. The options are:
* `.downloads` - implemented on all platforms, but 'faked' on iOS: files in this directory are not accessible to other users
* `.images` - implemented on Android and iOS only, and 'faked' on iOS: files in this directory are not accessible to other users
* `.video` - implemented on Android and iOS only, and 'faked' on iOS: files in this directory are not accessible to other users
* `.audio` - implemented on Android and iOS only, and 'faked' on iOS: files in this directory are not accessible to other users
* `.files` - implemented on Android only
* `.external` - implemented on Android only
The 'fake' on iOS is that we create an appropriately named subdirectory in the application's Documents directory where the file is moved to. iOS apps do not have access to the system wide directories.
Methods `moveToSharedStorage` and the similar `moveFileToSharedStorage` also take an optional
`directory` argument for a subdirectory in the `SharedStorage` destination. They also take an
optional `mimeType` parameter that overrides the mimeType derived from the filePath extension.
If the file already exists in shared storage, then on iOS and desktop it will be overwritten,
whereas on Android API 29+ a new file will be created with an indexed name (e.g. 'myFile (1).txt').
__On MacOS:__ For the `.downloads` to work you need to enable App Sandbox entitlements and set the key `com.apple.security.files.downloads.read-write` to true.
__On Android:__ Depending on what `SharedStorage` destination you move a file to, and depending on the OS version your app runs on, you _may_ require extra permissions `WRITE_EXTERNAL_STORAGE` and/or `READ_EXTERNAL_STORAGE` . See [here](https://medium.com/androiddevelopers/android-11-storage-faq-78cefea52b7c) for details on the new scoped storage rules starting with Android API version 30, which is what the plugin is using.
### Path to file in shared storage
To check if a file exists in shared storage, obtain the path to the file by calling
`pathInSharedStorage` and, if not null, check if that file exists.
__On Android 29+:__ If you
have generated a version with an indexed name (e.g. 'myFile (1).txt'), then only the most recently stored version is available this way, even if an earlier version actually does exist. Also, only files stored by your app will be returned via this call, as you don't have access to files stored by other apps.
__On iOS:__ To make files visible in the Files browser, do not move them to shared storage. Instead, download the file to the `BaseDirectory.applicationDocuments` and add the following to your `Info.plist`:
```
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UIFileSharingEnabled</key>
<true/>
```
This will make all files in your app's `Documents` directory visible to the Files browser.
## Uploads
Uploads are very similar to downloads, except:
* define an `UploadTask` object instead of a `DownloadTask`
* the file location now refers to the file you want to upload
* call `upload` instead of `download`, or `uploadBatch` instead of `downloadBatch`
There are two ways to upload a file to a server: binary upload (where the file is included in the POST body) and form/multi-part upload. Which type of upload is appropriate depends on the server you are uploading to. The upload will be done using the binary upload method only if you have set the `post` field of the `UploadTask` to 'binary'.
For multi-part uploads you can specify name/value pairs in the `fields` field of the `UploadTask` as a `Map<String, String>`. These will be uploaded as form fields along with the file. You can also set the field name used for the file itself by setting `fileField` (default is "file") and override the mimeType by setting `mimeType` (default is derived from filename extension).
If you need to upload multiple files in a single request, create a [MultiUploadTask](https://pub.dev/documentation/background_downloader/latest/background_downloader/MultiUploadTask-class.html) instead of an `UploadTask`. It has similar parameters as the `UploadTask`, except you specifiy a list of files to upload as the `files` argument of the constructor, and do not use `fileName`, `fileField` and `mimeType`. Each element in the `files` list is either:
* a filename (e.g. `"file1.txt"`). The `fileField` for that file will be set to the base name (i.e. "file1" for "file1.txt") and the mime type will be derived from the extension (i.e. "text/plain" for "file1.txt")
* a record containing `(fileField, filename)`, e.g. `("document", "file1.txt")`. The `fileField` for that file will be set to "document" and the mime type derived from the file extension (i.e. "text/plain" for "file1.txt")
* a record containing `(filefield, filename, mimeType)`, e.g. `("document", "file1.txt", "text/plain")`
The `baseDirectory` and `directory` fields of the `MultiUploadTask` determine the expected location of the file referenced, unless the filename used in any of the 3 formats above is an absolute path (e.g. "/data/user/0/com.my_app/file1.txt"). In that case, the absolute path is used and the `baseDirectory` and `directory` fields are ignored for that element of the list.
Once the `MultiUpoadTask` is created, the fields `fileFields`, `filenames` and `mimeTypes` will contain the parsed items, and the fields `fileField`, `filename` and `mimeType` contain those lists encoded as a JSON string.
Use the `MultiTaskUpload` object in the `upload` and `enqueue` methods as you would a regular `UploadTask`.
## Parallel downloads
Some servers may offer an option to download part of the same file from multiple URLs or have multiple parallel downloads of part of a large file using a single URL. This can speed up the download of large files. To do this, create a `ParallelDownloadTask` instead of a regular `DownloadTask` and specify `chunks` (the number of pieces you want to break the file into, i.e. the number of downloads that will happen in parallel) and `urls` (as a list of URLs, or just one). For example, if you specify 4 chunks and 2 URLs, then the download will be broken into 8 pieces, four each for each URL.
Note that the implementation of this feature creates a regular `DownloadTask` for each chunk, with the group name 'chunk' which is now a reserved group. You will not get updates for this group, but you will get normal updates (status and/or progress) for the `ParallelDownloadTask`.
## Managing tasks and the queue
### Canceling, pausing and resuming tasks
To enable pausing, set the `allowPause` field of the `Task` to `true`. This may also cause the task to `pause` un-commanded. For example, the OS may choose to pause the task if someone walks out of WiFi coverage.
To cancel, pause or resume a task, call:
* `cancelTaskWithId` to cancel the tasks with that taskId
* `cancelTasksWithIds` to cancel all tasks with a `taskId` in the provided list of taskIds
* `pause` to attempt to pause a task. Pausing is only possible for download GET requests, only if the `Task.allowPause` field is true, and only if the server supports pause/resume. Soon after the task is running (`TaskStatus.running`) you can call `taskCanResume` which will return a Future that resolves to `true` if the server appears capable of pause & resume. If it is not, then `pause` will have no effect and return false
* `resume` to resume a previously paused task (or certain failed tasks), which returns true if resume appears feasible. The task status will follow the same sequence as a newly enqueued task. If resuming turns out to be not feasible (e.g. the operating system deleted the temp file with the partial download) then the task will either restart as a normal download, or fail.
To manage or query the queue of waiting or running tasks, call:
* `reset` to reset the downloader, which cancels all ongoing download tasks
* `allTaskIds` to get a list of `taskId` values of all tasks currently active (i.e. not in a final state). You can exclude tasks waiting for retries by setting `includeTasksWaitingToRetry` to `false`. Note that paused tasks are not included in this list
* `allTasks` to get a list of all tasks currently active (i.e. not in a final state). You can exclude tasks waiting for retries by setting `includeTasksWaitingToRetry` to `false`. Note that paused tasks are not included in this list
* `taskForId` to get the `Task` for the given `taskId`, or `null` if not found.
* `tasksFinished` to check if all tasks have finished (successfully or otherwise)
Each of these methods accept a `group` parameter that targets the method to a specific group. If tasks are enqueued with a `group` other than default, calling any of these methods without a group parameter will not affect/include those tasks - only the default tasks. In particular, this may affect tasks started using a method like `download`, which changes the task's group to `FileDownloader.awaitGroup`.
**NOTE:** Only tasks that are active (ie. not in a final state) are guaranteed to be returned or counted, but returning a task does not guarantee that it is active.
This means that if you check `tasksFinished` when processing a task update, the task you received an update for may still show as 'active', even though it just finished, and result in `false` being returned. To fix this, pass that task's taskId as `ignoreTaskId` to the `tasksFinished` call, and it will be ignored for the purpose of testing if all tasks are finished:
```dart
void downloadStatusCallback(TaskStatusUpdate update) async {
// process your status update, then check if all tasks are finished
final bool allTasksFinished = update.status.isFinalState &&
await FileDownloader().tasksFinished(ignoreTaskId: update.task.taskId) ;
print('All tasks finished: $allTasksFinished');
}
```
### Grouping tasks
Because an app may require different types of downloads, and handle those differently, you can specify a `group` with your task, and register callbacks specific to each `group`. If no group is specified the default group `FileDownloader.defaultGroup` is used. For example, to create and handle downloads for group 'bigFiles':
```dart
FileDownloader().registerCallbacks(
group: 'bigFiles'
taskStatusCallback: bigFilesDownloadStatusCallback,
taskProgressCallback: bigFilesDownloadProgressCallback);
final task = DownloadTask(
group: 'bigFiles',
url: 'https://google.com',
filename: 'google.html',
updates: Updates.statusAndProgress);
final successFullyEnqueued = await FileDownloader().enqueue(task);
```
The methods `registerCallBacks`, `unregisterCallBacks`, `reset`, `allTaskIds`, `allTasks` and `tasksFinished` all take an optional `group` parameter to target tasks in a specific group. Note that if tasks are enqueued with a `group` other than default, calling any of these methods without a group parameter will not affect/include those tasks - only the default tasks.
If you listen to the `updates` stream instead of using callbacks, you can test for the task's `group` field in your listener, and process the update differently for different groups.
Note: tasks that are started using `download`, `upload`, `batchDownload` or `batchUpload` (where you `await` a result instead of `enqueue`ing a task) are assigned a special group name `FileDownloader.awaitGroup`, as callbacks for these tasks are handled within the `FileDownloader`, and will therefore not show up in your listener or callback.
### Task queues
Once you `enqueue` a task with the `FileDownloader` it is added to an internal queue that is managed by the native platform you're running on (e.g. Android). Once enqueued, you have limited control over the execution order, the number of tasks running in parallel, etc, because all that is managed by the platform. If you want more control over the queue, you need to add a `TaskQueue`.
The `MemoryTaskQueue` bundled with the `background_downloader` allows:
* pacing the rate of enqueueing tasks, based on `minInterval`, to avoid 'choking' the FileDownloader when adding a large number of tasks
* managing task priorities while waiting in the queue, such that higher priority tasks are enqueued before lower priority ones, even if they are added later
* managing the total number of tasks running concurrently, by setting `maxConcurrent`
* managing the number of tasks that talk to the same host concurrently, by setting `maxConcurrentByHost`
* managing the number of tasks running that are in the same `Task.group`, by setting `maxConcurrentByGroup`
A `TaskQueue` conceptually sits 'before' the FileDownloader's queue, and the `TaskQueue` makes the call to `FileDownloader().enqueue`. To use it, add it to the `FileDownloader` and instead of enqueuing tasks with the `FileDownloader`, you now `add` tasks to the queue:
```dart
final tq = MemoryTaskQueue();
tq.maxConcurrent = 5; // no more than 5 tasks active at any one time
tq.maxConcurrentByHost = 2; // no more than two tasks talking to the same host at the same time
tq.maxConcurrentByGroup = 3; // no more than three tasks from the same group active at the same time
FileDownloader().add(tq); // 'connects' the TaskQueue to the FileDownloader
FileDownloader().updates.listen((update) { // listen to updates as per usual
print('Received update for ${update.task.taskId}: $update')
});
for (var n = 0; n < 100; n++) {
task = DownloadTask(url: workingUrl, metData: 'task #$n'); // define task
tq.add(task); // add to queue. The queue makes the FileDownloader().enqueue call
}
```
Because it is possible that an error occurs when the taskQueue eventually actually enqueues the task with the FileDownloader, you can listen to the `enqueueErrors` stream for tasks that failed to enqueue.
A common use for the `MemoryTaskQueue` is enqueueing a large number of tasks. This can 'choke' the downloader if done in a loop, but is easy to do when adding all tasks to a queue. The `minInterval` field of the `MemoryTaskQueue` ensures that the tasks are fed to the `FileDownloader` at a rate that does not grind your app to a halt.
The default `TaskQueue` is the `MemoryTaskQueue` which, as the name suggests, keeps everything in memory. This is fine for most situations, but be aware that the queue may get dropped if the OS aggressively moves the app to the background. Tasks still waiting in the queue will not be enqueued, and will therefore be lost. If you want a `TaskQueue` with more persistence, or add different prioritzation and concurrency roles, then subclass the `MemoryTaskQueue` and add your own persistence or logic.
In addition, if your app is supended by the OS due to resource constraints, tasks waiting in the queue will not be enqueued to the native platform and will not run in the background. TaskQueues are therefore best for situations where you expect the queue to be emptied while the app is still in the foreground.
## Server requests
To make a regular server request (e.g. to obtain a response from an API end point that you process directly in your app) use the `request` method. It works similar to the `download` method, except you pass a `Request` object that has fewer fields than the `DownloadTask`, but is similar in structure. You `await` the response, which will be a [Response](https://pub.dev/documentation/http/latest/http/Response-class.html) object as defined in the dart [http package](https://pub.dev/packages/http), and includes getters for the response body (as a `String` or as `UInt8List`), `statusCode` and `reasonPhrase`.
Because requests are meant to be immediate, they are not enqueued like a `Task` is, and do not allow for status/progress monitoring.
## Optional parameters
The `DownloadTask`, `UploadTask` and `Request` objects all take several optional parameters that define how the task will be executed. Note that a `Task` is a subclass of `Request`, and both `DownloadTask` and `UploadTask` are subclasses of `Task`, so what applies to a `Request` or `Task` will also apply to a `DownloadTask` and `UploadTask`.
### Request, DownloadTask & UploadTask
#### urlQueryParameters
If provided, these parameters (presented as a `Map<String, String>`) will be appended to the url as query parameters. Note that both the `url` and `urlQueryParameters` must be urlEncoded (e.g. a space must be encoded as %20).
#### Headers
Optionally, `headers` can be added to the `Task`, which will be added to the HTTP request. This may be useful for authentication, for example.
#### HTTP request method
If provided, this request method will be used to make the request. By default, the request method is GET unless `post` is not null, or the `Task` is a `DownloadTask`, in which case it will be POST. Valid HTTP request methods are those listed in `Request.validHttpMethods`.
#### POST requests
For downloads, if the required server request is a HTTP POST request (instead of the default GET request) then set the `post` field of a `DownloadTask` to a `String` or `UInt8List` representing the data to be posted (for example, a JSON representation of an object). To make a POST request with no data, set `post` to an empty `String`.
For an `UploadTask` the POST field is used to request a binary upload, by setting it to 'binary'. By default, uploads are done using the form/multi-part format.
#### Retries
To schedule automatic retries of failed requests/tasks (with exponential backoff), set the `retries` field to an
integer between 1 and 10. A normal `Task` (without the need for retries) will follow status
updates from `enqueued` -> `running` -> `complete` (or `notFound`). If `retries` has been set and
the task fails, the sequence will be `enqueued` -> `running` ->
`waitingToRetry` -> `enqueued` -> `running` -> `complete` (if the second try succeeds, or more
retries if needed). A `Request` will behave similarly, except it does not provide intermediate status updates.
Note that certain failures can be resumed, and retries will therefore attempt to resume from a failure instead of retrying the task from scratch.
### DownloadTask & UploadTask
#### Requiring WiFi
If the `requiresWiFi` field of a `Task` is set to true, the task won't start unless a WiFi network is available. By default `requiresWiFi` is false, and downloads/uploads will use the cellular (or metered) network if WiFi is not available, which may incur cost.
#### Priority
The `priority` field must be 0 <= priority <= 10 with 0 being the highest priority, and defaults to 5. On Desktop and iOS all priority levels are supported. On Android, priority levels <5 are handled as 'expedited', and >=5 is handled as a normal task.
#### Metadata and displayName
`metaData` and `displayName` can be added to a `Task`. They are ignored by the downloader but may be helpful when receiving an update about the task, and can be shown in notifications using `{metaData}` or `{displayName}`.
### UploadTask
#### File field
Set `fileField` to the field name the server expects for the file portion of a multi-part upload. Defaults to "file".
#### Mime type
Set `mimeType` to the MIME type of the file to be uploaded. By default the MIME type is derived from the filename extension, e.g. a .txt file has MIME type `text/plain`.
#### Form fields
Set `fields` to a `Map<String, String>` of name/value pairs to upload as "form fields" along with the file.
## Initial setup
No setup is required for Windows or Linux.
### Android
No setup is required if you don't use notifications. If you do:
* Starting with API 33, you need to add `<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />` to your app's `AndroidManifest.xml`
* If needed, localize the button text by overriding string resources `bg_downloader_cancel`, `bg_downloader_pause`, `bg_downloader_resume` and descriptions `bg_downloader_notification_channel_name`, `bg_downloader_notification_channel_description`.
### iOS
On iOS, ensure that you have the Background Fetch capability enabled:
* Select the Runner target in XCode
* Select the Signing & Capabilities tab
* Click the + icon to add capabilities
* Select 'Background Modes'
* Tick the 'Background Fetch' mode
Note that iOS by default requires all URLs to be https (and not http). See [here](https://developer.apple.com/documentation/security/preventing_insecure_network_connections) for more details and how to address issues.
If using notifications, add the following to your `AppDelegate.swift`:
```swift
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
```
or if using Objective C, add to `AppDelegate.m`:
```objective-c
[UNUserNotificationCenter currentNotificationCenter].delegate = (id<UNUserNotificationCenterDelegate>) self;
```
### MacOS
MacOS needs you to request a specific entitlement in order to access the network. To do that open macos/Runner/DebugProfile.entitlements and add the following key-value pair.
```
<key>com.apple.security.network.client</key>
<true/>
```
Then do the same thing in macos/Runner/Release.entitlements.
## Configuration
Several aspects of the downloader can be configured on startup:
* Setting the request timeout value and, for iOS only, the 'resourceTimeout'
* Checking available space before attempting a download
* On Android, when to use the `cacheDir` for temporary files
* Setting a proxy
* Bypassing TLS Certificate validation (for debug mode only, Android and Desktop only)
* On Android, running tasks in 'foreground mode' to allow longer runs
* On Android, whether or not to use external storage
* On iOS, localizing the notification button texts
Please read the [configuration document](https://github.com/781flyingdutchman/background_downloader/blob/main/CONFIG.md) for details on how to configure.
## Limitations
* iOS 13.0 or greater; Android API 24 or greater
* On Android, downloads are by default limited to 9 minutes, after which the download will end with `TaskStatus.failed`. To allow for longer downloads, set the `DownloadTask.allowPause` field to true: if the task times out, it will pause and automatically resume, eventually downloading the entire file. Alternatively, [configure](#configuration) the downloader to allow tasks to run in the foreground
* On iOS, once enqueued (i.e. `TaskStatus.enqueued`), a background download must complete within 4 hours. [Configure](#configuration) 'resourceTimeout' to adjust.
* Redirects will be followed
* Background downloads and uploads are aggressively controlled by the native platform. You should therefore always assume that a task that was started may not complete, and may disappear without providing any status or progress update to indicate why. For example, if a user swipes your app up from the iOS App Switcher, all scheduled background downloads are terminated without notification

View file

@ -0,0 +1,4 @@
include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

View file

@ -0,0 +1,8 @@
export 'src/file_downloader.dart' show FileDownloader;
export 'src/models.dart';
export 'src/exceptions.dart';
export 'src/database.dart';
export 'src/persistent_storage.dart'
show PersistentStorage, PersistentStorageMigrator;
export 'src/progress_widget.dart';
export 'src/queue/task_queue.dart';

View file

@ -0,0 +1,689 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import 'database.dart';
import 'exceptions.dart';
import 'models.dart';
import 'persistent_storage.dart';
import 'queue/task_queue.dart';
import 'web_downloader.dart'
if (dart.library.io) 'desktop/desktop_downloader.dart';
/// Common download functionality
///
/// Concrete subclass will implement platform-specific functionality, eg
/// [DesktopDownloader] for dart based desktop platforms, and
/// [NativeDownloader] for iOS and Android
///
/// The common functionality mostly relates to:
/// - callback handling (for groups of tasks registered via the [FileDownloader])
/// - tasks waiting to retry and retry handling
/// - Task updates provided to the [FileDownloader]
/// - Pause/resume status and information
abstract base class BaseDownloader {
final log = Logger('BaseDownloader');
static const databaseVersion = 1;
/// Special group name for tasks that download a chunk, as part of a
/// [ParallelDownloadTask]
static const chunkGroup = 'chunk';
/// Persistent storage
late final PersistentStorage _storage;
late final Database database;
final tasksWaitingToRetry = <Task>{};
/// Registered [TaskStatusCallback] for each group
final groupStatusCallbacks = <String, TaskStatusCallback>{};
/// Registered [TaskProgressCallback] for each group
final groupProgressCallbacks = <String, TaskProgressCallback>{};
/// Registered [TaskNotificationTapCallback] for each group
final groupNotificationTapCallbacks = <String, TaskNotificationTapCallback>{};
/// List of notification configurations
final notificationConfigs = <TaskNotificationConfig>[];
/// StreamController for [TaskUpdate] updates
var updates = StreamController<TaskUpdate>();
/// Groups tracked in persistent database
final trackedGroups = <String?>{};
/// Map of tasks and completer to indicate whether task can be resumed
final canResumeTask = <Task, Completer<bool>>{};
/// Flag indicating we have retrieved missed data
var _retrievedLocallyStoredData = false;
/// Connected TaskQueues that will receive a signal upon task completion
final taskQueues = <TaskQueue>[];
BaseDownloader();
factory BaseDownloader.instance(
PersistentStorage persistentStorage, Database database) {
final instance = DesktopDownloader();
instance._storage = persistentStorage;
instance.database = database;
unawaited(instance.initialize());
return instance;
}
/// Initialize
///
/// Initializes the PersistentStorage instance and if necessary perform database
/// migration, then initializes the subclassed implementation for
/// desktop or native
@mustCallSuper
Future<void> initialize() => _storage.initialize();
/// 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
Future<List<(String, String)>> configure(
{dynamic globalConfig,
dynamic androidConfig,
dynamic iOSConfig,
dynamic desktopConfig}) async {
final global = globalConfig is List ? globalConfig : [globalConfig];
final rawPlatformConfig = platformConfig(
androidConfig: androidConfig,
iOSConfig: iOSConfig,
desktopConfig: desktopConfig);
final platform =
rawPlatformConfig is List ? rawPlatformConfig : [rawPlatformConfig];
return await Future.wait([...global, ...platform]
.where((e) => e != null)
.map((e) => configureItem(e)));
}
/// Returns the config for the platform, e.g. the [androidConfig] parameter
/// on Android
dynamic platformConfig(
{dynamic globalConfig,
dynamic androidConfig,
dynamic iOSConfig,
dynamic desktopConfig});
/// Configures one [configItem] and returns the (String, String) result
///
/// If the second element is 'ignored' then the method did not act on
/// the [configItem]
Future<(String, String)> configureItem((String, dynamic) configItem);
/// Retrieve data that was stored locally because it could not be
/// delivered to the downloader
Future<void> retrieveLocallyStoredData() async {
if (!_retrievedLocallyStoredData) {
final resumeDataMap = await popUndeliveredData(Undelivered.resumeData);
for (var taskId in resumeDataMap.keys) {
// map is <taskId, ResumeData>
final resumeData = ResumeData.fromJsonMap(resumeDataMap[taskId]);
await setResumeData(resumeData);
await setPausedTask(resumeData.task);
}
final statusUpdateMap =
await popUndeliveredData(Undelivered.statusUpdates);
for (var taskId in statusUpdateMap.keys) {
// map is <taskId, Task/TaskStatus> where TaskStatus is added to Task JSON
final payload = statusUpdateMap[taskId];
processStatusUpdate(TaskStatusUpdate.fromJsonMap(payload));
}
final progressUpdateMap =
await popUndeliveredData(Undelivered.progressUpdates);
for (var taskId in progressUpdateMap.keys) {
// map is <taskId, Task/progress> where progress is added to Task JSON
final payload = progressUpdateMap[taskId];
processProgressUpdate(TaskProgressUpdate.fromJsonMap(payload));
}
_retrievedLocallyStoredData = true;
}
}
/// Returns the [TaskNotificationConfig] for this [task] or null
///
/// Matches on task, then on group, then on default
TaskNotificationConfig? notificationConfigForTask(Task task) {
if (task.group == chunkGroup) {
return null;
}
return notificationConfigs
.firstWhereOrNull((config) => config.taskOrGroup == task) ??
notificationConfigs
.firstWhereOrNull((config) => config.taskOrGroup == task.group) ??
notificationConfigs
.firstWhereOrNull((config) => config.taskOrGroup == null);
}
/// Enqueue the task
@mustCallSuper
Future<bool> enqueue(Task task) async {
if (task.allowPause) {
canResumeTask[task] = Completer();
}
return true;
}
/// Resets the download worker by cancelling all ongoing tasks for the group
///
/// Returns the number of tasks canceled
@mustCallSuper
Future<int> reset(String group) async {
final retryCount =
tasksWaitingToRetry.where((task) => task.group == group).length;
tasksWaitingToRetry.removeWhere((task) => task.group == group);
final pausedTasks = await getPausedTasks();
var pausedCount = 0;
for (var task in pausedTasks) {
if (task.group == group) {
await removePausedTask(task.taskId);
pausedCount++;
}
}
return retryCount + pausedCount;
}
/// Returns a list of all tasks in progress, matching [group]
@mustCallSuper
Future<List<Task>> allTasks(
String group, bool includeTasksWaitingToRetry) async {
final tasks = <Task>[];
if (includeTasksWaitingToRetry) {
tasks.addAll(tasksWaitingToRetry.where((task) => task.group == group));
}
final pausedTasks = await getPausedTasks();
tasks.addAll(pausedTasks.where((task) => task.group == group));
return tasks;
}
/// Cancels ongoing tasks whose taskId is in the list provided with this call
///
/// Returns true if all cancellations were successful
@mustCallSuper
Future<bool> cancelTasksWithIds(List<String> taskIds) async {
final matchingTasksWaitingToRetry = tasksWaitingToRetry
.where((task) => taskIds.contains(task.taskId))
.toList(growable: false);
final matchingTaskIdsWaitingToRetry = matchingTasksWaitingToRetry
.map((task) => task.taskId)
.toList(growable: false);
// remove tasks waiting to retry from the list so they won't be retried
for (final task in matchingTasksWaitingToRetry) {
tasksWaitingToRetry.remove(task);
processStatusUpdate(TaskStatusUpdate(task, TaskStatus.canceled));
processProgressUpdate(TaskProgressUpdate(task, progressCanceled));
}
final remainingTaskIds = taskIds
.where((taskId) => !matchingTaskIdsWaitingToRetry.contains(taskId));
// cancel paused tasks
final pausedTasks = await getPausedTasks();
final pausedTaskIdsToCancel = pausedTasks
.where((task) => remainingTaskIds.contains(task.taskId))
.map((e) => e.taskId)
.toList(growable: false);
await cancelPausedPlatformTasksWithIds(pausedTasks, pausedTaskIdsToCancel);
// cancel remaining taskIds on the platform
final platformTaskIds = remainingTaskIds
.where((taskId) => !pausedTaskIdsToCancel.contains(taskId))
.toList(growable: false);
if (platformTaskIds.isEmpty) {
return true;
}
return cancelPlatformTasksWithIds(platformTaskIds);
}
/// Cancel these tasks on the platform
Future<bool> cancelPlatformTasksWithIds(List<String> taskIds);
/// Cancel paused tasks
///
/// Deletes the associated temp file and emits [TaskStatus.cancel]
Future<void> cancelPausedPlatformTasksWithIds(
List<Task> pausedTasks, List<String> taskIds) async {
for (final taskId in taskIds) {
final task =
pausedTasks.firstWhereOrNull((element) => element.taskId == taskId);
if (task != null) {
final resumeData = await getResumeData(task.taskId);
if (!Platform.isIOS && resumeData != null) {
final tempFilePath = resumeData.tempFilepath;
try {
await File(tempFilePath).delete();
} on FileSystemException {
log.fine('Could not delete temp file $tempFilePath');
}
}
processStatusUpdate(TaskStatusUpdate(task, TaskStatus.canceled));
processProgressUpdate(TaskProgressUpdate(task, progressCanceled));
}
}
}
/// Returns Task for this taskId, or nil
@mustCallSuper
Future<Task?> taskForId(String taskId) async {
try {
return tasksWaitingToRetry.where((task) => task.taskId == taskId).first;
} on StateError {
try {
final pausedTasks = await getPausedTasks();
return pausedTasks.where((task) => task.taskId == taskId).first;
} on StateError {
return null;
}
}
}
/// Activate tracking for tasks in this group
///
/// All subsequent tasks in this group will be recorded in persistent storage
/// and can be queried with methods that include 'tracked', e.g.
/// [allTrackedTasks]
///
/// If [markDownloadedComplete] is true (default) then all tasks that are
/// marked as not yet [TaskStatus.complete] will be set to complete if the
/// target file for that task exists, and will 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, provided you have registered your listeners
/// or callback before calling this.
Future<void> trackTasks(String? group, bool markDownloadedComplete) async {
trackedGroups.add(group);
if (markDownloadedComplete) {
final records = await database.allRecords(group: group);
for (var record in records.where((record) =>
record.task is DownloadTask &&
record.status != TaskStatus.complete)) {
final filePath = await record.task.filePath();
if (await File(filePath).exists()) {
processStatusUpdate(
TaskStatusUpdate(record.task, TaskStatus.complete));
final updatedRecord = record.copyWith(
status: TaskStatus.complete, progress: progressComplete);
await database.updateRecord(updatedRecord);
}
}
}
}
/// Attempt to pause this [task]
///
/// Returns true if successful
Future<bool> pause(Task task);
/// Attempt to resume this [task]
///
/// Returns true if successful
@mustCallSuper
Future<bool> resume(Task task) async {
await removePausedTask(task.taskId);
if (await getResumeData(task.taskId) != null) {
final currentCompleter = canResumeTask[task];
if (currentCompleter == null || currentCompleter.isCompleted) {
// create if didn't exist or was completed
canResumeTask[task] = Completer();
}
return true;
}
return false;
}
/// Sets the 'canResumeTask' flag for this task
///
/// Completes the completer already associated with this task
/// if it wasn't completed already
void setCanResume(Task task, bool canResume) {
if (canResumeTask[task]?.isCompleted == false) {
canResumeTask[task]?.complete(canResume);
}
}
/// Returns a Future that indicates whether this task can be resumed
///
/// If we have stored [ResumeData] this is true
/// If we have completer then we return its future
/// Otherwise we return false
Future<bool> taskCanResume(Task task) async {
if (await getResumeData(task.taskId) != null) {
return true;
}
if (canResumeTask.containsKey(task)) {
return canResumeTask[task]!.future;
}
return false;
}
/// Stores the resume data
Future<void> setResumeData(ResumeData resumeData) =>
_storage.storeResumeData(resumeData);
/// Retrieve the resume data for this [taskId]
Future<ResumeData?> getResumeData(String taskId) =>
_storage.retrieveResumeData(taskId);
/// Remove resumeData for this [taskId], or all if null
Future<void> removeResumeData([String? taskId]) =>
_storage.removeResumeData(taskId);
/// Store the paused [task]
Future<void> setPausedTask(Task task) => _storage.storePausedTask(task);
/// Return a stored paused task with this [taskId], or null if not found
Future<Task?> getPausedTask(String taskId) =>
_storage.retrievePausedTask(taskId);
/// Return a list of paused [Task] objects
Future<List<Task>> getPausedTasks() => _storage.retrieveAllPausedTasks();
/// Remove paused task for this taskId, or all if null
Future<void> removePausedTask([String? taskId]) =>
_storage.removePausedTask(taskId);
/// Retrieve data that was not delivered to Dart
Future<Map<String, dynamic>> popUndeliveredData(Undelivered dataType);
/// Clear pause and resume info associated with this [task]
void _clearPauseResumeInfo(Task task) {
canResumeTask.remove(task);
removeResumeData(task.taskId);
removePausedTask(task.taskId);
}
/// Move the file at [filePath] to the shared storage
/// [destination] and potential subdirectory [directory]
///
/// Returns the path to the file in shared storage, or null
Future<String?> moveToSharedStorage(String filePath,
SharedStorage destination, String directory, String? mimeType) {
return Future.value(null);
}
/// Returns the path to the file at [filePath] in shared storage
/// [destination] and potential subdirectory [directory], or null
Future<String?> pathInSharedStorage(
String filePath, SharedStorage destination, String directory) {
return Future.value(null);
}
/// 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
///
/// Precondition: either task or filename is not null
Future<bool> openFile(Task? task, String? filePath, String? mimeType);
/// Stores modified [modifiedTask] in local storage if [Task.group]
/// or [Task.updates] fields differ from [originalTask]
///
/// Modification happens in convenience functions, and storing the modified
/// version allows us to replace the original when used in pause/resume
/// functionality. Without this, a convenience download may not be able to
/// resume using the original [modifiedTask] object (as the [Task.group]
/// and [Task.updates] fields may have been modified)
Future<void> setModifiedTask(Task modifiedTask, Task originalTask) async {
if (modifiedTask.group != originalTask.group ||
modifiedTask.updates != originalTask.updates) {
await _storage.storeModifiedTask(modifiedTask);
}
}
// Testing methods
/// Get the duration for a task to timeout - Android only, for testing
@visibleForTesting
Future<Duration> getTaskTimeout();
/// Set forceFailPostOnBackgroundChannel for native downloader
@visibleForTesting
Future<void> setForceFailPostOnBackgroundChannel(bool value);
/// Test suggested filename based on task and content disposition header
@visibleForTesting
Future<String> testSuggestedFilename(
DownloadTask task, String contentDisposition);
// Helper methods
/// Retrieves modified version of the [originalTask] or null
///
/// See [setModifiedTask]
Future<Task?> getModifiedTask(Task originalTask) =>
_storage.retrieveModifiedTask(originalTask.taskId);
/// Remove modified [task], or all if null
Future<void> removeModifiedTask([Task? task]) =>
_storage.removeModifiedTask(task?.taskId);
/// Closes the [updates] stream and re-initializes the [StreamController]
/// such that the stream can be listened to again
Future<void> resetUpdatesStreamController() async {
if (updates.hasListener && !updates.isPaused) {
await updates.close();
}
updates = StreamController();
}
/// Process status update coming from Downloader and emit to listener
///
/// Also manages retries ([tasksWaitingToRetry] and delay) and pause/resume
/// ([pausedTasks] and [_clearPauseResumeInfo]
void processStatusUpdate(TaskStatusUpdate update) {
// Normal status updates are only sent here when the task is expected
// to provide those. The exception is a .failed status when a task
// has retriesRemaining > 0: those are always sent here, and are
// intercepted to hold the task and reschedule in the near future
final task = update.task;
if (update.status == TaskStatus.failed && task.retriesRemaining > 0) {
_emitStatusUpdate(TaskStatusUpdate(task, TaskStatus.waitingToRetry));
_emitProgressUpdate(TaskProgressUpdate(task, progressWaitingToRetry));
task.decreaseRetriesRemaining();
tasksWaitingToRetry.add(task);
final waitTime = Duration(
seconds: 2 << min(task.retries - task.retriesRemaining - 1, 8));
log.finer('TaskId ${task.taskId} failed, waiting ${waitTime.inSeconds}'
' seconds before retrying. ${task.retriesRemaining}'
' retries remaining');
Future.delayed(waitTime, () async {
// after delay, resume or enqueue task again if it's still waiting
if (tasksWaitingToRetry.remove(task)) {
if (!((await getResumeData(task.taskId) != null &&
await resume(task)) ||
await enqueue(task))) {
log.warning(
'Could not resume/enqueue taskId ${task.taskId} after retry timeout');
removeModifiedTask(task);
_clearPauseResumeInfo(task);
_emitStatusUpdate(TaskStatusUpdate(
task,
TaskStatus.failed,
TaskException(
'Could not resume/enqueue taskId${task.taskId} after retry timeout')));
_emitProgressUpdate(TaskProgressUpdate(task, progressFailed));
}
}
});
} else {
// normal status update
if (update.status == TaskStatus.paused) {
setPausedTask(task);
}
if (update.status.isFinalState) {
removeModifiedTask(task);
_clearPauseResumeInfo(task);
}
if (update.status.isFinalState || update.status == TaskStatus.paused) {
notifyTaskQueues(task);
}
_emitStatusUpdate(update);
}
}
/// Process progress update coming from Downloader to client listener
void processProgressUpdate(TaskProgressUpdate update) {
switch (update.progress) {
case progressComplete:
case progressFailed:
case progressNotFound:
case progressCanceled:
case progressPaused:
notifyTaskQueues(update.task);
default:
// no-op
}
_emitProgressUpdate(update);
}
/// Notify all [taskQueues] that this task has finished
void notifyTaskQueues(Task task) {
for (var taskQueue in taskQueues) {
taskQueue.taskFinished(task);
}
}
/// Process user tapping on a notification
///
/// Because a notification tap may cause the app to start from scratch, we
/// allow a few retries with backoff to let the app register a callback
Future<void> processNotificationTap(
Task task, NotificationType notificationType) async {
var retries = 0;
var success = false;
while (retries < 5 && !success) {
final notificationTapCallback = groupNotificationTapCallbacks[task.group];
if (notificationTapCallback != null) {
notificationTapCallback(task, notificationType);
success = true;
} else {
await Future.delayed(
Duration(milliseconds: 100 * pow(2, retries).round()));
retries++;
}
}
}
/// Emits the status update for this task to its callback or listener, and
/// update the task in the database
void _emitStatusUpdate(TaskStatusUpdate update) {
final task = update.task;
_updateTaskInDatabase(task,
status: update.status, taskException: update.exception);
if (task.providesStatusUpdates) {
final taskStatusCallback = groupStatusCallbacks[task.group];
if (taskStatusCallback != null) {
taskStatusCallback(update);
} else {
if (updates.hasListener) {
updates.add(update);
} else {
log.warning('Requested status updates for task ${task.taskId} in '
'group ${task.group} but no TaskStatusCallback '
'was registered, and there is no listener to the '
'updates stream');
}
}
}
}
/// Emit the progress update for this task to its callback or listener, and
/// update the task in the database
void _emitProgressUpdate(TaskProgressUpdate update) {
final task = update.task;
if (task.providesProgressUpdates) {
_updateTaskInDatabase(task,
progress: update.progress, expectedFileSize: update.expectedFileSize);
final taskProgressCallback = groupProgressCallbacks[task.group];
if (taskProgressCallback != null) {
taskProgressCallback(update);
} else if (updates.hasListener) {
updates.add(update);
} else {
log.warning('Requested progress updates for task ${task.taskId} in '
'group ${task.group} but no TaskProgressCallback '
'was registered, and there is no listener to the '
'updates stream');
}
}
}
/// Insert or update the [TaskRecord] in the tracking database
Future<void> _updateTaskInDatabase(Task task,
{TaskStatus? status,
double? progress,
int expectedFileSize = -1,
TaskException? taskException}) async {
if (trackedGroups.contains(null) || trackedGroups.contains(task.group)) {
if (status == null && progress != null) {
// update existing record with progress only (provided it's not 'paused')
final existingRecord = await database.recordForId(task.taskId);
if (existingRecord != null && progress != progressPaused) {
database.updateRecord(existingRecord.copyWith(progress: progress));
}
return;
}
if (progress == null && status != null) {
// set progress based on status
progress = switch (status) {
TaskStatus.enqueued || TaskStatus.running => 0.0,
TaskStatus.complete => progressComplete,
TaskStatus.notFound => progressNotFound,
TaskStatus.failed => progressFailed,
TaskStatus.canceled => progressCanceled,
TaskStatus.waitingToRetry => progressWaitingToRetry,
TaskStatus.paused => progressPaused
};
}
if (status != TaskStatus.paused) {
database.updateRecord(TaskRecord(
task, status!, progress!, expectedFileSize, taskException));
} else {
// if paused, don't modify the stored progress
final existingRecord = await database.recordForId(task.taskId);
database.updateRecord(TaskRecord(task, status!,
existingRecord?.progress ?? 0, expectedFileSize, taskException));
}
}
}
/// Destroy - clears callbacks, updates stream and retry queue
///
/// Clears all queues and references without sending cancellation
/// messages or status updates
@mustCallSuper
void destroy() {
tasksWaitingToRetry.clear();
groupStatusCallbacks.clear();
groupProgressCallbacks.clear();
notificationConfigs.clear();
trackedGroups.clear();
canResumeTask.clear();
removeResumeData(); // removes all
removePausedTask(); // removes all
removeModifiedTask(); // removes all
resetUpdatesStreamController();
}
}

View file

@ -0,0 +1,117 @@
import 'dart:convert';
import 'base_downloader.dart';
import 'file_downloader.dart';
import 'models.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 JsonMap
Chunk.fromJsonMap(Map<String, dynamic> jsonMap)
: parentTaskId = jsonMap['parentTaskId'],
url = jsonMap['url'],
filename = jsonMap['filename'],
fromByte = (jsonMap['fromByte'] as num).toInt(),
toByte = (jsonMap['toByte'] as num).toInt(),
task = Task.createFromJsonMap(jsonMap['task']) as DownloadTask,
status = TaskStatus.values[(jsonMap['status'] as num).toInt()],
progress = (jsonMap['progress'] as num).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.fromJsonMap(jsonDecode(value as String)) : value;
/// Creates JSON map of this object
Map<String, dynamic> toJsonMap() => {
'parentTaskId': parentTaskId,
'url': url,
'filename': filename,
'fromByte': fromByte,
'toByte': toByte,
'task': task.toJsonMap(),
'status': status.index,
'progress': progress
};
/// Creates JSON String of this object
String toJson() => jsonEncode(toJsonMap());
/// 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) {
Updates.none || Updates.status => Updates.status,
Updates.progress ||
Updates.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(
chunks.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;
}

View file

@ -0,0 +1,194 @@
import 'package:flutter/foundation.dart';
import 'base_downloader.dart';
import 'exceptions.dart';
import 'models.dart';
import 'persistent_storage.dart';
/// Persistent database used for tracking task status and progress.
///
/// Stores [TaskRecord] objects.
///
/// This object is accessed by the [Downloader] and [BaseDownloader]
interface class Database {
static Database? _instance;
late final PersistentStorage _storage;
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;
/// 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 result = <TaskRecord>[];
for (var taskId in taskIds) {
final record = await recordForId(taskId);
if (record != null) {
result.add(record);
}
}
return result;
}
/// Delete all records
///
/// Optionally, specify a [group] to filter by
Future<void> deleteAllRecords({String? group}) async {
if (group == null) {
await _storage.removeTaskRecord(null);
return;
}
final allRecordsInGroup = await allRecords(group: group);
await deleteRecordsWithIds(
allRecordsInGroup.map((record) => record.taskId));
}
/// Delete record with this [taskId]
Future<void> deleteRecordWithId(String taskId) =>
deleteRecordsWithIds([taskId]);
/// Delete records with these [taskIds]
Future<void> deleteRecordsWithIds(Iterable<String> taskIds) async {
for (var taskId in taskIds) {
await _storage.removeTaskRecord(taskId);
}
}
/// 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 =>
_storage.storeTaskRecord(record);
}
/// Record containing task, task status and task progress.
///
/// [TaskRecord] represents the state of the task as recorded in persistent
/// storage if [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 a JSON map
TaskRecord.fromJsonMap(Map<String, dynamic> jsonMap)
: task = Task.createFromJsonMap(jsonMap),
status = TaskStatus.values[
(jsonMap['status'] as num?)?.toInt() ?? TaskStatus.failed.index],
progress = (jsonMap['progress'] as num?)?.toDouble() ?? progressFailed,
expectedFileSize = (jsonMap['expectedFileSize'] as num?)?.toInt() ?? -1,
exception = jsonMap['exception'] == null
? null
: TaskException.fromJsonMap(jsonMap['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> toJsonMap() {
final jsonMap = task.toJsonMap();
jsonMap['status'] = status.index;
jsonMap['progress'] = progress;
jsonMap['expectedFileSize'] = expectedFileSize;
jsonMap['exception'] = exception?.toJsonMap();
return jsonMap;
}
/// 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() {
return '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;
}

View file

@ -0,0 +1,550 @@
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 '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');
final maxConcurrent = 10;
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 var httpClient = http.Client();
static Duration? _requestTimeout;
static var _proxy = <String, dynamic>{}; // 'address' and 'port'
static var _bypassTLSCertificateValidation = false;
factory DesktopDownloader() => _singleton;
DesktopDownloader._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;
}
/// 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 = _queue.removeFirst();
_running.add(task);
_executeTask(task).then((_) {
_remove(task);
_advanceQueue();
});
}
}
/// 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 {
final resumeData = await getResumeData(task.taskId);
if (resumeData != null) {
await removeResumeData(task.taskId);
}
final isResume = _resume.remove(task) && resumeData != null;
final filePath = await task.filePath(); // "" for MultiUploadTask
// 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
});
RootIsolateToken? rootIsolateToken = RootIsolateToken.instance;
if (rootIsolateToken == null) {
processStatusUpdate(TaskStatusUpdate(task, TaskStatus.failed,
TaskException('Could not obtain rootIsolateToken')));
return;
}
log.finer('${isResume ? "Resuming" : "Starting"} taskId ${task.taskId}');
await Isolate.spawn(doTask, (rootIsolateToken, receivePort.sendPort),
onError: errorPort.sendPort);
final messagesFromIsolate = StreamQueue<dynamic>(receivePort);
final sendPort = await messagesFromIsolate.next as SendPort;
sendPort.send((
task,
filePath,
resumeData,
isResume,
requestTimeout,
proxy,
bypassTLSCertificateValidation
));
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',
Task updatedTask,
TaskStatus status,
TaskException? exception,
String? responseBody
):
final taskStatusUpdate = TaskStatusUpdate(updatedTask, status,
status == TaskStatus.failed ? exception : null, responseBody);
if (updatedTask.group != BaseDownloader.chunkGroup) {
if (status.isFinalState) {
_remove(updatedTask);
}
processStatusUpdate(taskStatusUpdate);
} else {
_parallelTaskSendPort(Chunk.getParentTaskId(updatedTask))
?.send(taskStatusUpdate);
}
case (
'progressUpdate',
Task updatedTask,
double progress,
int expectedFileSize,
double downloadSpeed,
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', bool taskCanResume):
setCanResume(task, taskCanResume);
case ('resumeData', String data, int requiredStartByte, String? eTag):
setResumeData(ResumeData(task, data, requiredStartByte, eTag));
// from [ParallelDownloadTask]
case ('enqueueChild', DownloadTask childTask):
await FileDownloader().enqueue(childTask);
// from [ParallelDownloadTask]
case ('cancelTasksWithId', List<String> taskIds):
await FileDownloader().cancelTasksWithIds(taskIds);
// from [ParallelDownloadTask]
case ('pauseTasks', List<DownloadTask> tasks):
for (final chunkTask in tasks) {
await FileDownloader().pause(chunkTask);
}
case ('log', String logMessage):
_log.finest(logMessage);
default:
_log.warning('Received message with unknown type '
'$message from Isolate');
}
}
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) async {
final retryAndPausedTasks =
await super.allTasks(group, includeTasksWaitingToRetry);
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 {
var 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<bool> resume(Task task) async {
if (await super.resume(task)) {
_resume.add(task);
if (await enqueue(task)) {
if (task is ParallelDownloadTask) {
final resumeData = await getResumeData(task.taskId);
if (resumeData == null) {
return false;
}
return resumeChunkTasks(task, resumeData);
}
return true;
}
}
return false;
}
@override
Future<Map<String, dynamic>> popUndeliveredData(Undelivered dataType) =>
Future.value({});
@override
Future<String?> moveToSharedStorage(String filePath,
SharedStorage destination, String directory, String? mimeType) async {
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) {
_log.warning('Error moving $filePath to shared storage: $e');
return null;
}
return destFilePath;
}
@override
Future<String?> pathInSharedStorage(
String filePath, SharedStorage destination, String directory) async {
final destDirectoryPath =
await getDestinationDirectoryPath(destination, directory);
if (destDirectoryPath == null) {
return null;
}
final fileName = path.basename(filePath);
return path.join(destDirectoryPath, fileName);
}
/// 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 = Platform.isLinux
? 'xdg-open'
: Platform.isMacOS
? 'open'
: 'start';
filePath ??= await task!.filePath();
if (!await File(filePath).exists()) {
_log.fine('File to open does not exist: $filePath');
return false;
}
final result = 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, Duration? duration):
requestTimeout = duration;
case (Config.proxy, (String address, int port)):
proxy = {'address': address, 'port': port};
case (Config.proxy, false):
proxy = {};
case (Config.bypassTLSCertificateValidation, bool bypass):
bypassTLSCertificateValidation = bypass;
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 the HTTP Client to use, with the given parameters
///
/// This is a convenience method, bundling the [requestTimeout],
/// [proxy] and [bypassTLSCertificateValidation]
static void setHttpClient(Duration? requestTimeout,
Map<String, dynamic> proxy, bool bypassTLSCertificateValidation) {
_requestTimeout = requestTimeout;
_proxy = proxy;
_bypassTLSCertificateValidation = bypassTLSCertificateValidation;
_recreateClient();
}
/// Recreates the [httpClient] used for Requests and isolate downloads/uploads
static _recreateClient() {
final client = HttpClient();
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;
httpClient = IOClient(client);
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 and TLSCertificateBypass = $bypassTLSCertificateValidation');
}
@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);
}
}

View file

@ -0,0 +1,279 @@
import 'dart:async';
import 'dart:io';
import 'dart:isolate';
import 'dart:math';
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 '../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,
String filePath,
ResumeData? resumeData,
bool isResume,
Duration requestTimeout,
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;
// 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((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.httpClient;
var request =
http.Request(downloadTask.httpRequestMethod, Uri.parse(downloadTask.url));
request.headers.addAll(downloadTask.headers);
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;
}
if (downloadTask.post is String) {
request.body = downloadTask.post!;
}
var resultStatus = TaskStatus.failed;
try {
final response = await client.send(request);
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}');
}
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;
IOSink? outStream;
try {
// do the actual download
outStream = File(tempFilePath)
.openWrite(mode: isResume ? FileMode.append : FileMode.write);
final transferBytesResult = await transferBytes(response.stream, outStream,
contentLength, downloadTask, sendPort, requestTimeout);
switch (transferBytesResult) {
case TaskStatus.complete:
// copy file to destination, creating dirs if needed
await outStream.flush();
final dirPath = p.dirname(filePath);
Directory(dirPath).createSync(recursive: true);
File(tempFilePath).copySync(filePath);
resultStatus = TaskStatus.complete;
case TaskStatus.canceled:
deleteTempFile(tempFilePath);
resultStatus = TaskStatus.canceled;
case TaskStatus.paused:
if (taskCanResume) {
sendPort.send(
('resumeData', tempFilePath, bytesTotal + startByte, eTagHeader));
resultStatus = TaskStatus.paused;
} else {
taskException =
TaskResumeException('Task was paused but cannot resume');
resultStatus = TaskStatus.failed;
}
case TaskStatus.failed:
break;
default:
throw ArgumentError('Cannot process $transferBytesResult');
}
} catch (e) {
logError(downloadTask, e.toString());
setTaskError(e);
} finally {
try {
await outStream?.close();
if (resultStatus == TaskStatus.failed &&
serverAcceptsRanges &&
bytesTotal + startByte > 1 << 20) {
// send ResumeData to allow resume after fail
sendPort.send(
('resumeData', tempFilePath, bytesTotal + startByte, eTagHeader));
} else if (resultStatus != TaskStatus.paused) {
File(tempFilePath).deleteSync();
}
} catch (e) {
logError(downloadTask, 'Could not delete temp file $tempFilePath');
}
}
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);
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');
}
}

View file

@ -0,0 +1,373 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
import 'dart:math';
import 'package:async/async.dart';
import 'package:background_downloader/src/exceptions.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:logging/logging.dart';
import '../models.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;
TaskException? taskException;
String? responseBody;
// 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 task,
String filePath,
ResumeData? resumeData,
bool isResume,
Duration? requestTimeout,
Map<String, dynamic> proxy,
bool bypassTLSCertificateValidation
) = await messagesToIsolate.next;
DesktopDownloader.setHttpClient(
requestTimeout, proxy, bypassTLSCertificateValidation);
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((LogRecord rec) {
if (kDebugMode) {
sendPort.send(('log', (rec.message)));
}
});
// 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) {
logError(task, 'task has negative retries remaining');
taskException = TaskException('Task has negative retries remaining');
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,
filePath,
resumeData,
isResume,
requestTimeout ?? const Duration(seconds: 60),
sendPort),
DownloadTask() => doDownloadTask(task, filePath, resumeData, isResume,
requestTimeout ?? const Duration(seconds: 60), sendPort),
UploadTask() => doUploadTask(task, filePath, sendPort)
};
}
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 TaskStatusUpdate update:
await chunkStatusUpdate(task, update, sendPort);
case 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 TaskStatus.complete:
processProgressUpdateInIsolate(task, progressComplete, sendPort);
case TaskStatus.failed when !retryNeeded:
processProgressUpdateInIsolate(task, progressFailed, sendPort);
case TaskStatus.canceled:
processProgressUpdateInIsolate(task, progressCanceled, sendPort);
case TaskStatus.notFound:
processProgressUpdateInIsolate(task, progressNotFound, sendPort);
case TaskStatus.paused:
processProgressUpdateInIsolate(task, progressPaused, sendPort);
default:
{}
}
// Post update if task expects one, or if failed and retry is needed
if (task.providesStatusUpdates || retryNeeded) {
sendPort.send((
'statusUpdate',
task,
status,
status == TaskStatus.failed
? taskException ?? TaskException('None')
: null,
status.isFinalState ? responseBody : null
));
}
}
/// 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)
));
}
}
}
// 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 (!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]+$');
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);
/// 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) {
switch (e.runtimeType) {
case IOException:
taskException = TaskFileSystemException(e.toString());
case HttpException:
case TimeoutException:
taskException = TaskConnectionException(e.toString());
case TaskException:
taskException = e;
default:
taskException = 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] + threshold and
/// [now] > [nextProgressUpdateTime]
bool shouldSendProgressUpdate(double currentProgress, DateTime now) {
return currentProgress - lastProgressUpdate > 0.02 &&
now.isAfter(nextProgressUpdateTime);
}

View file

@ -0,0 +1,382 @@
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 '../utils.dart';
import 'desktop_downloader.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 final 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,
String filePath,
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.httpClient
.head(Uri.parse(task.url), headers: task.headers);
if ([200, 201, 202, 203, 204, 205, 206].contains(response.statusCode)) {
chunks = createChunks(task, response.headers);
for (var 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));
break;
case TaskStatus.failed:
taskException = update.exception;
responseBody = update.responseBody;
cancelAllChunkTasks(sendPort);
parallelTaskStatusUpdateCompleter.complete(TaskStatusUpdate(
task, TaskStatus.failed, taskException, responseBody));
break;
case TaskStatus.notFound:
responseBody = update.responseBody;
cancelAllChunkTasks(sendPort);
parallelTaskStatusUpdateCompleter.complete(
TaskStatusUpdate(task, TaskStatus.notFound, null, responseBody));
break;
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');
}
}

View file

@ -0,0 +1,199 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
import 'package:background_downloader/src/exceptions.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import '../models.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, String filePath, SendPort sendPort) async {
final resultStatus = task.post == 'binary'
? await binaryUpload(task, filePath, sendPort)
: await multipartUpload(task, filePath, sendPort);
processStatusUpdateInIsolate(task, resultStatus, sendPort);
}
/// Do the binary upload and return the TaskStatus
///
/// Sends updates via the [sendPort] and can be commanded to cancel via
/// the [messagesToIsolate] queue
Future<TaskStatus> binaryUpload(
UploadTask task, String filePath, SendPort sendPort) async {
final inFile = File(filePath);
if (!inFile.existsSync()) {
logError(task, 'file to upload does not exist: $filePath');
taskException =
TaskFileSystemException('File to upload does not exist: $filePath');
return TaskStatus.failed;
}
final fileSize = inFile.lengthSync();
// determine the content length of the multi-part data
var resultStatus = TaskStatus.failed;
try {
final client = DesktopDownloader.httpClient;
final request =
http.StreamedRequest(task.httpRequestMethod, Uri.parse(task.url));
request.headers.addAll(task.headers);
request.contentLength = fileSize;
request.headers['Content-Type'] = task.mimeType;
// 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);
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();
transferBytesResult =
await transferBytes(inStream, request.sink, fileSize, task, sendPort);
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 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<TaskStatus> multipartUpload(
UploadTask task, String filePath, SendPort sendPort) async {
// field portion of the multipart, all in one string
var fieldsString = '';
for (var entry in task.fields.entries) {
fieldsString += fieldEntry(entry.key, entry.value);
}
// 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 = filePath.isNotEmpty
? [(task.fileField, filePath, task.mimeType)] // one file Upload case
: await task.extractFilesData(); // MultiUpload 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 TaskStatus.failed;
}
contentDispositionStrings.add(
'Content-Disposition: form-data; name="${browserEncode(fileField)}"; '
'filename="${browserEncode(p.basename(file.path))}"$lineFeed',
);
contentTypeStrings.add('Content-Type: $mimeType$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.httpClient;
final request =
http.StreamedRequest(task.httpRequestMethod, Uri.parse(task.url));
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'
});
// 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);
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 (var (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 resultStatus;
}

View file

@ -0,0 +1,120 @@
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 implements Exception {
final String description;
TaskException(this.description);
String get exceptionType => 'TaskException';
/// Create object from JSON Map
factory TaskException.fromJsonMap(Map<String, dynamic> jsonMap) {
final typeString = jsonMap['type'] as String? ?? 'TaskException';
final exceptionType = _exceptions[typeString];
final description = jsonMap['description'] as String? ?? '';
if (exceptionType != null) {
if (typeString != 'TaskHttpException') {
return exceptionType(description);
} else {
final httpResponseCode =
(jsonMap['httpResponseCode'] as num?)?.toInt() ?? -1;
return 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;
if (typeString != 'TaskHttpException') {
return exceptionType(description);
} else {
return exceptionType(description, httpResponseCode);
}
}
/// Return JSON Map representing object
Map<String, dynamic> toJsonMap() =>
{'type': exceptionType, 'description': description};
/// Return JSON String representing object
String toJson() => jsonEncode(toJsonMap());
@override
String toString() {
return '$exceptionType: $description';
}
}
/// Exception related to the filesystem, e.g. insufficient space
/// or file not found
final class TaskFileSystemException extends TaskException {
TaskFileSystemException(super.description);
@override
String get exceptionType => 'TaskFileSystemException';
}
/// Exception related to the url, eg malformed
final class TaskUrlException extends TaskException {
TaskUrlException(super.description);
@override
String get exceptionType => 'TaskUrlException';
}
/// Exception related to the connection, e.g. socket exception
/// or request timeout
final class TaskConnectionException extends TaskException {
TaskConnectionException(super.description);
@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 extends TaskException {
TaskResumeException(super.description);
@override
String get exceptionType => 'TaskResumeException';
}
/// Exception related to the HTTP response, e.g. a 403
/// response code
final class TaskHttpException extends TaskException {
final int httpResponseCode;
TaskHttpException(super.description, this.httpResponseCode);
@override
String get exceptionType => 'TaskHttpException';
@override
Map<String, dynamic> toJsonMap() =>
{...super.toJsonMap(), 'httpResponseCode': httpResponseCode};
@override
String toString() {
return '$exceptionType, response code $httpResponseCode: $description';
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,102 @@
part of localstore;
/// 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 {
return 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 (var key in docs.keys) {
final id = key.split(pathSeparatorRegEx).last;
DocumentRef(id, this)._data.clear();
}
}
await _utils.delete(path);
}
}

View file

@ -0,0 +1,32 @@
part of localstore;
/// 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(
field, {
isEqualTo,
});
/// Delete collection
///
/// All collections and documents in this collection will be deleted.
Future<void> delete();
}

View file

@ -0,0 +1,71 @@
part of localstore;
/// 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);
Map<String, dynamic>? input = _data[id] ?? {};
output.updateAll((key, value) {
input![key] = value;
});
_data[id] = input;
} else {
_data[id] = data;
}
_utils.set(_data[id], path);
}
@override
Future<Map<String, dynamic>?> get() async {
return _data[id] ?? await _utils.get(path);
}
@override
Future delete() async {
await _utils.delete(path);
_data.remove(id);
}
@override
CollectionRef collection(String id) {
return CollectionRef(id, _delegate, this);
}
@override
String toString() {
return _utils.toString();
}
}

View file

@ -0,0 +1,20 @@
part of localstore;
/// 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();
}

View file

@ -0,0 +1,18 @@
library localstore;
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';

View file

@ -0,0 +1,32 @@
part of localstore;
/// 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) {
return CollectionRef(path, null, _delegate);
}
}

View file

@ -0,0 +1,7 @@
part of localstore;
/// The interface that other Localstore must extend.
abstract class LocalstoreImpl {
/// Gets a [CollectionRef] for the specified Localstore path.
CollectionRef collection(String path);
}

View file

@ -0,0 +1,14 @@
part of localstore;
/// 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({bool merge = false}) : _merge = merge;
}

View file

@ -0,0 +1,223 @@
import 'dart:async';
import 'dart:convert';
// ignore: avoid_web_libraries_in_flutter
import 'dart:html' as html;
import 'utils_impl.dart';
/// Utils class
class Utils implements UtilsImpl {
Utils._();
static final Utils _utils = Utils._();
static Utils get instance => _utils;
@override
void clearCache() {
// no cache on web
}
@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) {
var dataCol = html.window.localStorage.entries.singleWhere(
(e) => e.key == path,
orElse: () => const MapEntry('', ''),
);
if (dataCol.key != '') {
if (conditions != null && conditions.first.isNotEmpty) {
return _getAll(dataCol);
/*
final ck = conditions.first[0] as String;
final co = conditions.first[1];
final cv = conditions.first[2];
// With conditions
try {
final mapCol = json.decode(dataCol.value) as Map<String, dynamic>;
final its = SplayTreeMap.of(mapCol);
its.removeWhere((key, value) {
if (value is Map<String, dynamic>) {
final key = value.keys.contains(ck);
final check = value[ck] as bool;
return !(key == true && check == cv);
}
return false;
});
its.forEach((key, value) {
final data = value as Map<String, dynamic>;
_data[key] = data;
});
return _data;
} catch (error) {
throw error;
}
*/
} else {
return _getAll(dataCol);
}
}
} else {
final data = await _readFromStorage(path);
final id = path.substring(path.lastIndexOf('/') + 1, path.length);
if (data is Map<String, dynamic>) {
if (data.containsKey(id)) return data[id];
return null;
}
}
return null;
}
@override
Future<dynamic>? set(Map<String, dynamic> data, String path) {
return _writeToStorage(data, path);
}
@override
Future delete(String path) async {
_deleteFromStorage(path);
}
@override
Stream<Map<String, dynamic>> stream(String path, [List<List>? conditions]) {
// ignore: close_sinks
final storage = _storageCache[path] ??
_storageCache.putIfAbsent(
path, () => StreamController<Map<String, dynamic>>.broadcast());
_initStream(storage, path);
return storage.stream;
}
Map<String, dynamic>? _getAll(MapEntry<String, String> dataCol) {
final items = <String, dynamic>{};
try {
final mapCol = json.decode(dataCol.value) as Map<String, dynamic>;
mapCol.forEach((key, value) {
final data = value as Map<String, dynamic>;
items[key] = data;
});
if (items.isEmpty) return null;
return items;
} catch (error) {
rethrow;
}
}
void _initStream(
StreamController<Map<String, dynamic>> storage, String path) {
var dataCol = html.window.localStorage.entries.singleWhere(
(e) => e.key == path,
orElse: () => const MapEntry('', ''),
);
try {
if (dataCol.key != '') {
final mapCol = json.decode(dataCol.value) as Map<String, dynamic>;
mapCol.forEach((key, value) {
final data = value as Map<String, dynamic>;
storage.add(data);
});
}
} catch (error) {
rethrow;
}
}
final _storageCache = <String, StreamController<Map<String, dynamic>>>{};
Future<dynamic> _readFromStorage(String path) async {
final key = path.replaceAll(RegExp(r'[^\/]+\/?$'), '');
final data = html.window.localStorage.entries.firstWhere(
(i) => i.key == key,
orElse: () => const MapEntry('', ''),
);
if (data != const MapEntry('', '')) {
try {
return json.decode(data.value) as Map<String, dynamic>;
} catch (e) {
return e;
}
}
}
Future<dynamic> _writeToStorage(
Map<String, dynamic> data,
String path,
) async {
final key = path.replaceAll(RegExp(r'[^\/]+\/?$'), '');
final uri = Uri.parse(path);
final id = uri.pathSegments.last;
var dataCol = html.window.localStorage.entries.singleWhere(
(e) => e.key == key,
orElse: () => const MapEntry('', ''),
);
try {
if (dataCol.key != '') {
final mapCol = json.decode(dataCol.value) as Map<String, dynamic>;
mapCol[id] = data;
dataCol = MapEntry(id, json.encode(mapCol));
html.window.localStorage.update(
key,
(value) => dataCol.value,
ifAbsent: () => dataCol.value,
);
} else {
html.window.localStorage.update(
key,
(value) => json.encode({id: data}),
ifAbsent: () => json.encode({id: data}),
);
}
// ignore: close_sinks
final storage = _storageCache[key] ??
_storageCache.putIfAbsent(
key, () => StreamController<Map<String, dynamic>>.broadcast());
storage.sink.add(data);
} catch (error) {
rethrow;
}
}
Future<dynamic> _deleteFromStorage(String path) async {
if (path.endsWith('/')) {
// If path is a directory path
final dataCol = html.window.localStorage.entries.singleWhere(
(element) => element.key == path,
orElse: () => const MapEntry('', ''),
);
try {
if (dataCol.key != '') {
html.window.localStorage.remove(dataCol.key);
}
} catch (error) {
rethrow;
}
} else {
// If path is a file path
final uri = Uri.parse(path);
final key = path.replaceAll(RegExp(r'[^\/]+\/?$'), '');
final id = uri.pathSegments.last;
var dataCol = html.window.localStorage.entries.singleWhere(
(e) => e.key == key,
orElse: () => const MapEntry('', ''),
);
try {
if (dataCol.key != '') {
final mapCol = json.decode(dataCol.value) as Map<String, dynamic>;
mapCol.remove(id);
html.window.localStorage.update(
key,
(value) => json.encode(mapCol),
ifAbsent: () => dataCol.value,
);
}
} catch (error) {
rethrow;
}
}
}
}

View file

@ -0,0 +1,220 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
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>{};
/// Clears the cache
@override
void clearCache() {
_storageCache.clear();
_fileCache.clear();
}
@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 = '${dbDir.path}$path';
final dir = Directory(fullPath);
if (!await dir.exists()) {
return {};
}
List<FileSystemEntity> entries =
dir.listSync(recursive: false).whereType<File>().toList();
return await _getAll(entries);
} else {
try {
// Reads the document referenced by this [DocumentRef].
final file = await _getFile(path);
final randomAccessFile = file!.openSync(mode: FileMode.append);
final data = await _readFile(randomAccessFile);
randomAccessFile.closeSync();
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;
}
} on PathNotFoundException {
// return null if not found
}
}
return null;
}
@override
Future<dynamic>? set(Map<String, dynamic> data, String path) {
return _writeFile(data, path);
}
@override
Future delete(String path) async {
if (path.endsWith(Platform.pathSeparator)) {
await _deleteDirectory(path);
} else {
await _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 {
final items = <String, dynamic>{};
final dbDir = await Localstore.instance.databaseDirectory;
await Future.forEach(entries, (FileSystemEntity e) async {
final path = e.path.replaceAll(dbDir.path, '');
final file = await _getFile(path);
try {
final randomAccessFile = await file!.open(mode: FileMode.append);
final data = await _readFile(randomAccessFile);
await randomAccessFile.close();
if (data is Map<String, dynamic>) {
items[path] = data;
}
} on PathNotFoundException {
// ignore if not found
}
});
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 = '${dbDir.path}$path';
final dir = Directory(fullPath);
try {
List<FileSystemEntity> entries =
dir.listSync(recursive: false).whereType<File>().toList();
for (var e in entries) {
final path = e.path.replaceAll(dbDir.path, '');
final file = await _getFile(path);
final randomAccessFile = file!.openSync(mode: FileMode.append);
_readFile(randomAccessFile).then((data) {
randomAccessFile.closeSync();
if (data is Map<String, dynamic>) {
storage.add(data);
}
});
}
} catch (e) {
return e;
}
}
Future<dynamic> _readFile(RandomAccessFile file) async {
final length = file.lengthSync();
file.setPositionSync(0);
final buffer = Uint8List(length);
file.readIntoSync(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 {
if (_fileCache.containsKey(path)) return _fileCache[path];
final dbDir = await Localstore.instance.databaseDirectory;
final file = File('${dbDir.path}$path');
if (!file.existsSync()) file.createSync(recursive: true);
_fileCache.putIfAbsent(path, () => file);
return file;
}
Future _writeFile(Map<String, dynamic> data, String path) async {
final serialized = json.encode(data);
final buffer = utf8.encode(serialized);
final file = await _getFile(path);
try {
final randomAccessFile = file!.openSync(mode: FileMode.append);
randomAccessFile.lockSync();
randomAccessFile.setPositionSync(0);
randomAccessFile.writeFromSync(buffer);
randomAccessFile.truncateSync(buffer.length);
randomAccessFile.unlockSync();
randomAccessFile.closeSync();
} on PathNotFoundException {
// ignore if path not found
}
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('${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('${dbDir.path}$path');
if (await dir.exists()) {
try {
await dir.delete(recursive: true);
} catch (e) {
_log.finest(e);
}
}
_fileCache.removeWhere((key, value) => key.startsWith(path));
}
}

View file

@ -0,0 +1,8 @@
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]);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,445 @@
import 'dart:async';
import 'dart:io';
import 'package:background_downloader/src/base_downloader.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'database.dart';
import 'localstore/localstore.dart';
import 'models.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
/// - modified [Task]s, keyed by taskId
/// - [ResumeData], keyed by taskId
///
/// Each of the objects has a toJsonMap method and can be created using
/// fromJsonMap (use .createFromJsonMap 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 modified tasks
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 a modified [task], keyed by taskId
Future<void> storeModifiedTask(Task task);
/// Retrieve modified [Task] with [taskId], or null if not found
Future<Task?> retrieveModifiedTask(String taskId);
/// Retrieve all modified [Task]
Future<List<Task>> retrieveAllModifiedTasks();
/// Remove modified [Task] with [taskId] from storage. If null, remove all
Future<void> removeModifiedTask(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();
}
typedef JsonMap = Map<String, dynamic>;
/// Default implementation of [PersistentStorage] using Localstore package
class LocalStorePersistentStorage implements PersistentStorage {
final log = Logger('LocalStorePersistentStorage');
final _db = Localstore.instance;
final _illegalPathCharacters = RegExp(r'[\\/:*?"<>|]');
static const taskRecordsPath = 'backgroundDownloaderTaskRecords';
static const resumeDataPath = 'backgroundDownloaderResumeData';
static const pausedTasksPath = 'backgroundDownloaderPausedTasks';
static const modifiedTasksPath = 'backgroundDownloaderModifiedTasks';
static const metaDataCollection = 'backgroundDownloaderDatabase';
/// Stores [JsonMap] formatted [document] in [collection] keyed under [identifier]
Future<void> store(
JsonMap document, String collection, String identifier) async {
await _db.collection(collection).doc(identifier).set(document);
}
/// Returns [document] stored in [collection] under key [identifier]
/// as a [JsonMap], or null if not found
Future<JsonMap?> retrieve(String collection, String identifier) =>
_db.collection(collection).doc(identifier).get();
/// Returns all documents in collection as a [JsonMap] keyed by the
/// document identifier, with the value a [JsonMap] representing the document
Future<JsonMap> retrieveAll(String collection) async {
return 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, '_');
@override
Future<void> removeModifiedTask(String? taskId) =>
remove(modifiedTasksPath, _safeIdOrNull(taskId));
@override
Future<void> removePausedTask(String? taskId) =>
remove(pausedTasksPath, _safeIdOrNull(taskId));
@override
Future<void> removeResumeData(String? taskId) =>
remove(resumeDataPath, _safeIdOrNull(taskId));
@override
Future<void> removeTaskRecord(String? taskId) =>
remove(taskRecordsPath, _safeIdOrNull(taskId));
@override
Future<List<Task>> retrieveAllModifiedTasks() async {
final jsonMaps = await retrieveAll(modifiedTasksPath);
return jsonMaps.values
.map((e) => Task.createFromJsonMap(e))
.toList(growable: false);
}
@override
Future<List<Task>> retrieveAllPausedTasks() async {
final jsonMaps = await retrieveAll(pausedTasksPath);
return jsonMaps.values
.map((e) => Task.createFromJsonMap(e))
.toList(growable: false);
}
@override
Future<List<ResumeData>> retrieveAllResumeData() async {
final jsonMaps = await retrieveAll(resumeDataPath);
return jsonMaps.values
.map((e) => ResumeData.fromJsonMap(e))
.toList(growable: false);
}
@override
Future<List<TaskRecord>> retrieveAllTaskRecords() async {
final jsonMaps = await retrieveAll(taskRecordsPath);
return jsonMaps.values
.map((e) => TaskRecord.fromJsonMap(e))
.toList(growable: false);
}
@override
Future<Task?> retrieveModifiedTask(String taskId) async {
return switch (await retrieve(modifiedTasksPath, _safeId(taskId))) {
var jsonMap? => Task.createFromJsonMap(jsonMap),
_ => null
};
}
@override
Future<Task?> retrievePausedTask(String taskId) async {
return switch (await retrieve(pausedTasksPath, _safeId(taskId))) {
var jsonMap? => Task.createFromJsonMap(jsonMap),
_ => null
};
}
@override
Future<ResumeData?> retrieveResumeData(String taskId) async {
return switch (await retrieve(resumeDataPath, _safeId(taskId))) {
var jsonMap? => ResumeData.fromJsonMap(jsonMap),
_ => null
};
}
@override
Future<TaskRecord?> retrieveTaskRecord(String taskId) async {
return switch (await retrieve(taskRecordsPath, _safeId(taskId))) {
var jsonMap? => TaskRecord.fromJsonMap(jsonMap),
_ => null
};
}
@override
Future<void> storeModifiedTask(Task task) =>
store(task.toJsonMap(), modifiedTasksPath, _safeId(task.taskId));
@override
Future<void> storePausedTask(Task task) =>
store(task.toJsonMap(), pausedTasksPath, _safeId(task.taskId));
@override
Future<void> storeResumeData(ResumeData resumeData) =>
store(resumeData.toJsonMap(), resumeDataPath, _safeId(resumeData.taskId));
@override
Future<void> storeTaskRecord(TaskRecord record) =>
store(record.toJsonMap(), taskRecordsPath, _safeId(record.taskId));
@override
Future<(String, int)> get storedDatabaseVersion async {
final metaData =
await _db.collection(metaDataCollection).doc('metaData').get();
return ('Localstore', (metaData?['version'] as num?)?.toInt() ?? 0);
}
@override
(String, int) get currentDatabaseVersion => ('Localstore', 1);
@override
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();
for (String path in [
resumeDataPath,
pausedTasksPath,
modifiedTasksPath,
taskRecordsPath
]) {
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);
await Directory(fromPath).list().forEach((entity) {
if (entity is File) {
entity.copySync(join(toPath, basename(entity.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});
}
}
/// Migrates from several possible persistent storage solutions to another
class PersistentStorageMigrator {
final log = Logger('PersistentStorageMigrator');
/// Create [PersistentStorageMigrator] object to migrate between persistent
/// storage solutions
///
/// Currently supported databases we can migrate from are:
/// * local_store (the default implementation of the database in
/// background_downloader). Migration from local_store to
/// [SqlitePersistentStorage] is complete, i.e. all state is transferred.
/// * flutter_downloader (a popular but now deprecated package for
/// downloading files). Migration from flutter_downloader is partial: only
/// tasks that were complete, failed or canceled are transferred, and
/// if the location of a file cannot be determined as a combination of
/// [BaseDirectory] and [directory] then the task's baseDirectory field
/// will be set to [BaseDirectory.applicationDocuments] and its
/// directory field will be set to the 'savedDir' field of the database
/// used by flutter_downloader. You will have to determine what that
/// directory resolves to (likely an external directory on Android)
///
/// To add other migrations, extend this class and inject it in the
/// [PersistentStorage] class that you want to migrate to, such as
/// [SqlitePersistentStorage] or use it independently.
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) async {
for (var 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),
'flutterdownloader' => _migrateFromFlutterDownloader(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();
for (final pausedTask in await fromStorage.retrieveAllPausedTasks()) {
await toStorage.storePausedTask(pausedTask);
migratedSomething = true;
}
for (final modifiedTask in await fromStorage.retrieveAllModifiedTasks()) {
await toStorage.storeModifiedTask(modifiedTask);
migratedSomething = true;
}
for (final resumeData in await fromStorage.retrieveAllResumeData()) {
await toStorage.storeResumeData(resumeData);
migratedSomething = true;
}
for (final taskRecord in await fromStorage.retrieveAllTaskRecords()) {
await toStorage.storeTaskRecord(taskRecord);
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
///
/// A second example is the [_migrateFromFlutterDownloader] method
Future<bool> _migrateFromLocalStore(PersistentStorage toStorage) async {
final localStore = LocalStorePersistentStorage();
if (await _migrateFromPersistentStorage(localStore, toStorage)) {
// delete all paths related to LocalStore
final supportDir = await getApplicationSupportDirectory();
for (String collectionPath in [
LocalStorePersistentStorage.resumeDataPath,
LocalStorePersistentStorage.pausedTasksPath,
LocalStorePersistentStorage.modifiedTasksPath,
LocalStorePersistentStorage.taskRecordsPath,
LocalStorePersistentStorage.metaDataCollection
]) {
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
}
/// Attempt to migrate from FlutterDownloader
///
/// Return true if successful. Successful migration removes the original
/// data
Future<bool> _migrateFromFlutterDownloader(
PersistentStorage toStorage) async {
if (!(Platform.isAndroid || Platform.isIOS)) {
return false;
}
return false; // we did not migrate a database
}
}

View file

@ -0,0 +1,379 @@
import 'dart:async';
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'file_downloader.dart';
import 'models.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,
Key? key})
: super(key: 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 TaskStatus.running:
totalTasks.add(update.task);
pausedTasks.remove(update.task);
inProgress[update.task] =
(0, DateTime.now().millisecondsSinceEpoch);
case TaskStatus.waitingToRetry:
break;
case TaskStatus.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,
{Key? key})
: super(key: key);
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, 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,
{Key? key})
: super(key: key);
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,
{Key? key})
: super(key: key);
final List<Task> tasks;
final String message;
final double height;
final Color backgroundColor;
final Map<Task, (double, int)> inProgress;
@override
Widget build(BuildContext context) {
return Container(
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) {
return 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, 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(),
),
);
}
}

View file

@ -0,0 +1,202 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:logging/logging.dart';
import '../file_downloader.dart';
import '../models.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);
}
/// 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
var _readyForEnqueue = Completer();
final _enqueueErrorsStreamController = StreamController<Task>();
MemoryTaskQueue() {
_readyForEnqueue.complete();
}
/// 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();
} else {
removeTasksWithGroup(group);
final tasksToRemove =
enqueued.where((task) => task.group != group).toList(growable: false);
for (final task in tasksToRemove) {
enqueued.remove(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 (_readyForEnqueue.isCompleted) {
final task = getNextTask();
if (task == null) {
return;
}
_readyForEnqueue = Completer();
enqueued.add(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) {
var task = waiting.removeFirst();
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)) {
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) => enqueued.fold(
0,
(previousValue, task) =>
task.hostName == hostname ? previousValue + 1 : previousValue);
/// Returns number of tasks active with this group
int numActiveWithGroup(String group) => enqueued.fold(
0,
(previousValue, task) =>
task.group == group ? previousValue + 1 : previousValue);
/// 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;
}

View file

@ -0,0 +1,55 @@
import 'package:logging/logging.dart';
import 'models.dart';
final _log = Logger('FileDownloader');
/// 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) {
var 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
var 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;
}

View file

@ -0,0 +1,74 @@
import 'dart:async';
import 'package:http/http.dart' as http;
import 'base_downloader.dart';
import 'models.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 var httpClient = http.Client();
static Duration? requestTimeout;
static var proxy = <String, dynamic>{}; // 'address' and 'port'
static var bypassTLSCertificateValidation = false;
static void setHttpClient(Duration? requestTimeout,
Map<String, dynamic> proxy, bool bypassTLSCertificateValidation) {
requestTimeout = requestTimeout;
proxy = proxy;
bypassTLSCertificateValidation = bypassTLSCertificateValidation;
}
@override
Future<bool> cancelPlatformTasksWithIds(List<String> taskIds) {
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, dynamic>> popUndeliveredData(Undelivered dataType) {
throw UnimplementedError();
}
@override
Future<void> setForceFailPostOnBackgroundChannel(bool value) {
throw UnimplementedError();
}
@override
Future<String> testSuggestedFilename(
DownloadTask task, String contentDisposition) {
throw UnimplementedError();
}
}

View file

@ -0,0 +1,33 @@
name: background_downloader
description: A multi-platform background file downloader and uploader. Define the task, enqueue and monitor progress
version: 7.12.3
repository: https://github.com/781flyingdutchman/background_downloader
environment:
sdk: ^3.0.0
flutter: '>=3.0.0'
dependencies:
flutter:
sdk: flutter
logging: ^1.0.2
http: ^1.1.0
path_provider: ^2.0.2
path: ^1.8.1
async: ^2.6.0
mime: ^1.0.1
collection: ^1.15.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^2.0.0
platforms:
android:
ios:
linux:
macos:
windows:

View file

@ -0,0 +1,259 @@
// ignore_for_file: avoid_print
import 'dart:convert';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter_test/flutter_test.dart';
const urlWithContentLength = 'https://storage.googleapis'
'.com/approachcharts/test/5MB-test.ZIP';
const urlWithLongContentLength = 'https://storage.googleapis'
'.com/approachcharts/test/57MB-test.ZIP';
final task = DownloadTask(
taskId: 'taskId',
url: 'url',
urlQueryParameters: {'a': 'b'},
filename: 'filename',
headers: {'c': 'd'},
httpRequestMethod: 'GET',
baseDirectory: BaseDirectory.temporary,
directory: 'dir',
group: 'group',
updates: Updates.statusAndProgress,
requiresWiFi: true,
retries: 5,
allowPause: true,
metaData: 'metaData',
creationTime: DateTime.fromMillisecondsSinceEpoch(1000));
const downloadTaskJsonString =
'{"url":"url?a=b","headers":{"c":"d"},"httpRequestMethod":"GET","post":null,"retries":5,"retriesRemaining":5,"creationTime":1000,"taskId":"taskId","filename":"filename","directory":"dir","baseDirectory":1,"group":"group","updates":3,"requiresWiFi":true,"allowPause":true,"metaData":"metaData","taskType":"DownloadTask"}';
const downloadTaskJsonStringDoubles =
'{"url":"url?a=b","headers":{"c":"d"},"httpRequestMethod":"GET","post":null,"retries":5.0,"retriesRemaining":5.0,"creationTime":1000.0,"taskId":"taskId","filename":"filename","directory":"dir","baseDirectory":1.0,"group":"group","updates":3.0,"requiresWiFi":true,"allowPause":true,"metaData":"metaData","taskType":"DownloadTask"}';
final uploadTask = UploadTask(
taskId: 'taskId',
url: 'url',
urlQueryParameters: {'a': 'b'},
filename: 'filename',
headers: {'c': 'd'},
httpRequestMethod: 'PUT',
fileField: 'fileField',
fields: {'e': 'f'},
baseDirectory: BaseDirectory.temporary,
directory: 'dir',
group: 'group',
updates: Updates.statusAndProgress,
requiresWiFi: true,
retries: 5,
metaData: 'metaData',
creationTime: DateTime.fromMillisecondsSinceEpoch(1000));
const uploadTaskJsonString =
'{"url":"url?a=b","headers":{"c":"d"},"httpRequestMethod":"PUT","post":null,"retries":5,"retriesRemaining":5,"creationTime":1000,"taskId":"taskId","filename":"filename","directory":"dir","baseDirectory":1,"group":"group","updates":3,"requiresWiFi":true,"allowPause":false,"metaData":"metaData","taskType":"UploadTask","fileField":"fileField","mimeType":"application/octet-stream","fields":{"e":"f"}}';
const uploadTaskJsonStringDoubles =
'{"url":"url?a=b","headers":{"c":"d"},"httpRequestMethod":"PUT","post":null,"retries":5.0,"retriesRemaining":5.0,"creationTime":1000.0,"taskId":"taskId","filename":"filename","directory":"dir","baseDirectory":1.0,"group":"group","updates":3.0,"requiresWiFi":true,"allowPause":false,"metaData":"metaData","fileField":"fileField","mimeType":"application/octet-stream","fields":{"e":"f"},"taskType":"UploadTask"}';
void main() {
group('JSON conversion', () {
test('DownloadTask', () {
final task2 = Task.createFromJsonMap(jsonDecode(downloadTaskJsonString));
expect(task2, equals(task));
expect(jsonEncode(task2.toJsonMap()), equals(downloadTaskJsonString));
final task3 =
Task.createFromJsonMap(jsonDecode(downloadTaskJsonStringDoubles));
expect(jsonEncode(task3.toJsonMap()), equals(downloadTaskJsonString));
});
test('UploadTask', () {
final task2 = Task.createFromJsonMap(jsonDecode(uploadTaskJsonString));
expect(jsonEncode(task2.toJsonMap()), equals(uploadTaskJsonString));
final task3 =
Task.createFromJsonMap(jsonDecode(uploadTaskJsonStringDoubles));
expect(jsonEncode(task3.toJsonMap()), equals(uploadTaskJsonString));
});
test('MultiUploadTask', () async {
// try with list of Strings
var muTask = MultiUploadTask(
taskId: 'task1',
url: urlWithContentLength,
files: ['f1.txt', 'f2.txt']);
expect(muTask.fileFields, equals(['f1', 'f2']));
expect(muTask.filenames, equals(['f1.txt', 'f2.txt']));
expect(muTask.mimeTypes, equals(['text/plain', 'text/plain']));
expect(muTask.fileField, equals('["f1","f2"]')); // json string
expect(muTask.filename, equals('["f1.txt","f2.txt"]')); // json string
expect(muTask.mimeType,
equals('["text/plain","text/plain"]')); // json string
var muTask2 = MultiUploadTask.fromJsonMap(muTask.toJsonMap());
expect(muTask2.taskId, equals(muTask.taskId));
expect(muTask2.fileFields, equals(muTask.fileFields));
expect(muTask2.filenames, equals(muTask.filenames));
expect(muTask2.mimeTypes, equals(muTask.mimeTypes));
expect(muTask2.fileField, equals(muTask.fileField));
expect(muTask2.filename, equals(muTask.filename));
expect(muTask2.mimeType, equals(muTask.mimeType));
// try with list of (String, String)
muTask = MultiUploadTask(
taskId: 'task2',
url: urlWithContentLength,
files: [('file1', 'f1.txt'), ('file2', 'f2.txt')]);
expect(muTask.fileFields, equals(['file1', 'file2']));
expect(muTask.filenames, equals(['f1.txt', 'f2.txt']));
expect(muTask.mimeTypes, equals(['text/plain', 'text/plain']));
expect(muTask.fileField, equals('["file1","file2"]'));
expect(muTask.filename, equals('["f1.txt","f2.txt"]'));
expect(muTask.mimeType, equals('["text/plain","text/plain"]'));
muTask2 = MultiUploadTask.fromJsonMap(muTask.toJsonMap());
expect(muTask2.taskId, equals(muTask.taskId));
expect(muTask2.fileFields, equals(muTask.fileFields));
expect(muTask2.filenames, equals(muTask.filenames));
expect(muTask2.mimeTypes, equals(muTask.mimeTypes));
expect(muTask2.fileField, equals(muTask.fileField));
expect(muTask2.filename, equals(muTask.filename));
expect(muTask2.mimeType, equals(muTask.mimeType));
//try with list of (String, String, String)
muTask = MultiUploadTask(
taskId: 'task3',
url: urlWithContentLength,
files: [('file1', 'f1.txt', 'text/plain'), ('file2', 'f2')]);
expect(muTask.fileFields, equals(['file1', 'file2']));
expect(muTask.filenames, equals(['f1.txt', 'f2']));
expect(
muTask.mimeTypes, equals(['text/plain', 'application/octet-stream']));
expect(muTask.fileField, equals('["file1","file2"]'));
expect(muTask.filename, equals('["f1.txt","f2"]'));
expect(
muTask.mimeType, equals('["text/plain","application/octet-stream"]'));
muTask2 = MultiUploadTask.fromJsonMap(muTask.toJsonMap());
expect(muTask2.taskId, equals(muTask.taskId));
expect(muTask2.fileFields, equals(muTask.fileFields));
expect(muTask2.filenames, equals(muTask.filenames));
expect(muTask2.mimeTypes, equals(muTask.mimeTypes));
expect(muTask2.fileField, equals(muTask.fileField));
expect(muTask2.filename, equals(muTask.filename));
expect(muTask2.mimeType, equals(muTask.mimeType));
// check taskType
expect(muTask.toJsonMap()['taskType'], equals('MultiUploadTask'));
});
test('ParallelDownloadTask', () async {
// single url
var pdlTask = ParallelDownloadTask(url: urlWithLongContentLength);
expect(pdlTask.urls, equals([urlWithLongContentLength]));
expect(pdlTask.chunks, equals(1));
expect(pdlTask.url, equals(urlWithLongContentLength));
var pdlTask2 = ParallelDownloadTask.fromJsonMap(pdlTask.toJsonMap());
expect(pdlTask2, equals(pdlTask));
expect(pdlTask2.urls, equals(pdlTask.urls));
expect(pdlTask2.chunks, equals(pdlTask.chunks));
expect(pdlTask2.url, equals(pdlTask.url));
// multiple url with url parameters and chunks
pdlTask = ParallelDownloadTask(
url: [urlWithLongContentLength, urlWithContentLength],
urlQueryParameters: {'a': 'b'},
chunks: 5);
expect(
pdlTask.urls,
equals(
['$urlWithLongContentLength?a=b', '$urlWithContentLength?a=b']));
expect(pdlTask.chunks, equals(5));
expect(pdlTask.url, equals('$urlWithLongContentLength?a=b'));
pdlTask2 = ParallelDownloadTask.fromJsonMap(pdlTask.toJsonMap());
expect(pdlTask2, equals(pdlTask));
expect(pdlTask2.urls, equals(pdlTask.urls));
expect(pdlTask2.chunks, equals(pdlTask.chunks));
});
test('TaskStatusUpdate', () {
final statusUpdate = TaskStatusUpdate(
task, TaskStatus.failed, TaskConnectionException('test'));
const expected =
'{"task":{"url":"url?a=b","headers":{"c":"d"},"httpRequestMethod":"GET","post":null,"retries":5,"retriesRemaining":5,"creationTime":1000,"taskId":"taskId","filename":"filename","directory":"dir","baseDirectory":1,"group":"group","updates":3,"requiresWiFi":true,"allowPause":true,"metaData":"metaData","taskType":"DownloadTask"},"taskStatus":4,"exception":{"type":"TaskConnectionException","description":"test"},"responseBody":null}';
expect(jsonEncode(statusUpdate.toJsonMap()), equals(expected));
final update2 = TaskStatusUpdate.fromJsonMap(jsonDecode(expected));
expect(update2.task, equals(statusUpdate.task));
expect(update2.status, equals(TaskStatus.failed));
expect(update2.exception?.description, equals('test'));
expect(update2.exception is TaskConnectionException, isTrue);
const withDoubles =
'{"url":"url?a=b","headers":{"c":"d"},"httpRequestMethod":"GET","post":null,"retries":5.0,"retriesRemaining":5.0,"creationTime":1000.0,"taskId":"taskId","filename":"filename","directory":"dir","baseDirectory":1.0,"group":"group","updates":3.0,"requiresWiFi":true,"allowPause":true,"metaData":"metaData","taskType":"DownloadTask","taskStatus":4.0,"exception":{"type":"TaskConnectionException","description":"test"}}';
expect(
jsonEncode(TaskStatusUpdate.fromJsonMap(jsonDecode(withDoubles))
.toJsonMap()),
equals(expected));
});
test('TaskProgressUpdate', () {
final progressUpdate = TaskProgressUpdate(task, 1, 123);
const expected =
'{"task":{"url":"url?a=b","headers":{"c":"d"},"httpRequestMethod":"GET","post":null,"retries":5,"retriesRemaining":5,"creationTime":1000,"taskId":"taskId","filename":"filename","directory":"dir","baseDirectory":1,"group":"group","updates":3,"requiresWiFi":true,"allowPause":true,"metaData":"metaData","taskType":"DownloadTask"},"progress":1.0,"expectedFileSize":123,"networkSpeed":-1.0,"timeRemaining":-1}';
expect(jsonEncode(progressUpdate.toJsonMap()), equals(expected));
final update2 = TaskProgressUpdate.fromJsonMap(jsonDecode(expected));
expect(update2.task, equals(progressUpdate.task));
expect(update2.progress, equals(1));
expect(update2.expectedFileSize, equals(123));
const withDoubles =
'{"url":"url?a=b","headers":{"c":"d"},"httpRequestMethod":"GET","post":null,"retries":5.0,"retriesRemaining":5.0,"creationTime":1000.0,"taskId":"taskId","filename":"filename","directory":"dir","baseDirectory":1.0,"group":"group","updates":3.0,"requiresWiFi":true,"allowPause":true,"metaData":"metaData","taskType":"DownloadTask","progress":1,"expectedFileSize":123.0}';
expect(
jsonEncode(TaskProgressUpdate.fromJsonMap(jsonDecode(withDoubles))
.toJsonMap()),
equals(expected));
});
test('TaskRecord', () {
final taskRecord =
TaskRecord(task, TaskStatus.failed, 1, 123, TaskUrlException('test'));
const expected =
'{"url":"url?a=b","headers":{"c":"d"},"httpRequestMethod":"GET","post":null,"retries":5,"retriesRemaining":5,"creationTime":1000,"taskId":"taskId","filename":"filename","directory":"dir","baseDirectory":1,"group":"group","updates":3,"requiresWiFi":true,"allowPause":true,"metaData":"metaData","taskType":"DownloadTask","status":4,"progress":1.0,"expectedFileSize":123,"exception":{"type":"TaskUrlException","description":"test"}}';
expect(jsonEncode(taskRecord.toJsonMap()), equals(expected));
final update2 = TaskRecord.fromJsonMap(jsonDecode(expected));
expect(update2.task, equals(taskRecord.task));
expect(update2.status, equals(TaskStatus.failed));
expect(update2.exception?.description, equals('test'));
expect(update2.exception is TaskUrlException, isTrue);
expect(update2.progress, equals(1));
expect(update2.expectedFileSize, equals(123));
const withDoubles =
'{"url":"url?a=b","headers":{"c":"d"},"httpRequestMethod":"GET","post":null,"retries":5.0,"retriesRemaining":5.0,"creationTime":1000.0,"taskId":"taskId","filename":"filename","directory":"dir","baseDirectory":1,"group":"group","updates":3,"requiresWiFi":true,"allowPause":true,"metaData":"metaData","taskType":"DownloadTask","status":4.0,"progress":1,"expectedFileSize":123.0,"exception":{"type":"TaskUrlException","description":"test"}}';
expect(
jsonEncode(
TaskRecord.fromJsonMap(jsonDecode(withDoubles)).toJsonMap()),
equals(expected));
});
test('ResumeData', () {
final resumeData = ResumeData(task, 'data', 123, 'tag');
const expected =
'{"task":{"url":"url?a=b","headers":{"c":"d"},"httpRequestMethod":"GET","post":null,"retries":5,"retriesRemaining":5,"creationTime":1000,"taskId":"taskId","filename":"filename","directory":"dir","baseDirectory":1,"group":"group","updates":3,"requiresWiFi":true,"allowPause":true,"metaData":"metaData","taskType":"DownloadTask"},"data":"data","requiredStartByte":123,"eTag":"tag"}';
expect(jsonEncode(resumeData.toJsonMap()), equals(expected));
final update2 = ResumeData.fromJsonMap(jsonDecode(expected));
expect(update2.task, equals(resumeData.task));
expect(update2.data, equals('data'));
expect(update2.requiredStartByte, equals(123));
expect(update2.eTag, equals('tag'));
const withDoubles =
'{"task":{"url":"url?a=b","headers":{"c":"d"},"httpRequestMethod":"GET","post":null,"retries":5.0,"retriesRemaining":5.0,"creationTime":1000.0,"taskId":"taskId","filename":"filename","directory":"dir","baseDirectory":1.0,"group":"group","updates":3.0,"requiresWiFi":true,"allowPause":true,"metaData":"metaData","taskType":"DownloadTask"},"data":"data","requiredStartByte":123.0,"eTag":"tag"}';
expect(
jsonEncode(
ResumeData.fromJsonMap(jsonDecode(withDoubles)).toJsonMap()),
equals(expected));
final resumeData2 = ResumeData(task, 'data', 123, null);
const expected2 =
'{"task":{"url":"url?a=b","headers":{"c":"d"},"httpRequestMethod":"GET","post":null,"retries":5,"retriesRemaining":5,"creationTime":1000,"taskId":"taskId","filename":"filename","directory":"dir","baseDirectory":1,"group":"group","updates":3,"requiresWiFi":true,"allowPause":true,"metaData":"metaData","taskType":"DownloadTask"},"data":"data","requiredStartByte":123,"eTag":null}';
expect(jsonEncode(resumeData2.toJsonMap()), equals(expected2));
final update3 = ResumeData.fromJsonMap(jsonDecode(expected2));
expect(update3.task, equals(resumeData.task));
expect(update3.data, equals('data'));
expect(update3.requiredStartByte, equals(123));
expect(update3.eTag, isNull);
});
test('DownloadTask incoming from Android', () {
const incoming =
'{"allowPause":false,"baseDirectory": 1,"chunks":1,"creationTime":1694879914883,"directory":"","fields":{},"fileField":"","filename":"com.bbflight.background_downloader.1186323287","group":"chunk","headers":{"Range":"bytes\u003d0-29836749"},"httpRequestMethod":"GET","metaData":"{\\"parentTaskId\\":\\"3069222547\\",\\"from\\":0,\\"to\\":29836749}","mimeType":"","requiresWiFi":false,"retries":0,"retriesRemaining":0,"taskId":"1702658487","taskType":"DownloadTask","updates":2,"url":"https://storage.googleapis.com/approachcharts/test/57MB-test.ZIP","urls":[]}';
final task = Task.createFromJsonMap(jsonDecode(incoming));
print(task);
});
});
}

View file

@ -0,0 +1,148 @@
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter_test/flutter_test.dart';
const workingUrl = 'https://google.com';
const failingUrl = 'https://avmaps-dot-bbflightserver-hrd.appspot'
'.com/public/get_current_app_data?key=background_downloader_integration_test';
const urlWithContentLength = 'https://storage.googleapis'
'.com/approachcharts/test/5MB-test.ZIP';
const urlWithLongContentLength = 'https://storage.googleapis'
'.com/approachcharts/test/57MB-test.ZIP';
const getTestUrl =
'https://avmaps-dot-bbflightserver-hrd.appspot.com/public/test_get_data';
const getRedirectTestUrl =
'https://avmaps-dot-bbflightserver-hrd.appspot.com/public/test_get_redirect';
const postTestUrl =
'https://avmaps-dot-bbflightserver-hrd.appspot.com/public/test_post_data';
const uploadTestUrl =
'https://avmaps-dot-bbflightserver-hrd.appspot.com/public/test_upload_file';
const uploadBinaryTestUrl =
'https://avmaps-dot-bbflightserver-hrd.appspot.com/public/test_upload_binary_file';
const uploadMultiTestUrl =
'https://avmaps-dot-bbflightserver-hrd.appspot.com/public/test_multi_upload_file';
const urlWithContentLengthFileSize = 6207471;
const defaultFilename = 'google.html';
const postFilename = 'post.txt';
const uploadFilename = 'a_file.txt';
const uploadFilename2 = 'second_file.txt';
const largeFilename = '5MB-test.ZIP';
var task = DownloadTask(url: workingUrl, filename: defaultFilename);
var retryTask =
DownloadTask(url: failingUrl, filename: defaultFilename, retries: 3);
var uploadTask = UploadTask(url: uploadTestUrl, filename: uploadFilename);
var uploadTaskBinary = uploadTask.copyWith(post: 'binary');
void main() {
test('TaskProgressUpdate', () {
final task = DownloadTask(url: 'http://google.com');
var update = TaskProgressUpdate(task, 0.1);
expect(update.hasExpectedFileSize, isFalse);
expect(update.hasNetworkSpeed, isFalse);
expect(update.hasTimeRemaining, isFalse);
expect(update.networkSpeedAsString, equals('-- MB/s'));
expect(update.timeRemainingAsString, equals('--:--'));
update =
TaskProgressUpdate(task, 0.1, 123, 0.2, const Duration(seconds: 30));
expect(update.hasExpectedFileSize, isTrue);
expect(update.hasNetworkSpeed, isTrue);
expect(update.hasTimeRemaining, isTrue);
expect(update.networkSpeedAsString, equals('200 kB/s'));
expect(update.timeRemainingAsString, equals('00:30'));
update = TaskProgressUpdate(task, 0.1, 123, 2, const Duration(seconds: 90));
expect(update.networkSpeedAsString, equals('2 MB/s'));
expect(update.timeRemainingAsString, equals('01:30'));
update =
TaskProgressUpdate(task, 0.1, 123, 1.1, const Duration(seconds: 3610));
expect(update.networkSpeedAsString, equals('1 MB/s'));
expect(update.timeRemainingAsString, equals('1:00:10'));
});
test('copyWith', () async {
final complexTask = DownloadTask(
taskId: 'uniqueId',
url: postTestUrl,
filename: defaultFilename,
headers: {'Auth': 'Test'},
httpRequestMethod: 'PATCH',
post: 'TestPost',
directory: 'directory',
baseDirectory: BaseDirectory.temporary,
group: 'someGroup',
updates: Updates.statusAndProgress,
requiresWiFi: true,
retries: 5,
metaData: 'someMetaData');
final now = DateTime.now();
expect(
now.difference(complexTask.creationTime).inMilliseconds, lessThan(100));
final task = complexTask.copyWith(); // all the same
expect(task.taskId, equals(complexTask.taskId));
expect(task.url, equals(complexTask.url));
expect(task.filename, equals(complexTask.filename));
expect(task.headers, equals(complexTask.headers));
expect(task.httpRequestMethod, equals(complexTask.httpRequestMethod));
expect(task.post, equals(complexTask.post));
expect(task.directory, equals(complexTask.directory));
expect(task.baseDirectory, equals(complexTask.baseDirectory));
expect(task.group, equals(complexTask.group));
expect(task.updates, equals(complexTask.updates));
expect(task.requiresWiFi, equals(complexTask.requiresWiFi));
expect(task.retries, equals(complexTask.retries));
expect(task.retriesRemaining, equals(complexTask.retriesRemaining));
expect(task.retriesRemaining, equals(task.retries));
expect(task.metaData, equals(complexTask.metaData));
expect(task.creationTime, equals(complexTask.creationTime));
});
test('downloadTask url and urlQueryParameters', () {
final task0 = DownloadTask(
url: 'url with space',
filename: defaultFilename,
urlQueryParameters: {});
expect(task0.url, equals('url with space'));
final task1 = DownloadTask(
url: 'url',
filename: defaultFilename,
urlQueryParameters: {'param1': '1', 'param2': 'with space'});
expect(task1.url, equals('url?param1=1&param2=with space'));
final task2 = DownloadTask(
url: 'url?param0=0',
filename: defaultFilename,
urlQueryParameters: {'param1': '1', 'param2': 'with space'});
expect(task2.url, equals('url?param0=0&param1=1&param2=with space'));
final task4 =
DownloadTask(url: urlWithContentLength, filename: defaultFilename);
expect(task4.url, equals(urlWithContentLength));
});
test('downloadTask filename', () {
final task0 = DownloadTask(url: workingUrl);
expect(task0.filename.isNotEmpty, isTrue);
final task1 = DownloadTask(url: workingUrl, filename: defaultFilename);
expect(task1.filename, equals(defaultFilename));
expect(
() =>
DownloadTask(url: workingUrl, filename: 'somedir/$defaultFilename'),
throwsArgumentError);
});
test('downloadTask hasFilename and ?', () {
final task0 = DownloadTask(url: workingUrl);
expect(task0.hasFilename, isTrue);
final task1 = DownloadTask(url: workingUrl, filename: '?');
expect(task1.hasFilename, isFalse);
});
test('downloadTask directory', () {
final task0 = DownloadTask(url: workingUrl);
expect(task0.directory.isEmpty, isTrue);
final task1 = DownloadTask(url: workingUrl, directory: 'testDir');
expect(task1.directory, equals('testDir'));
expect(() => DownloadTask(url: workingUrl, directory: '/testDir'),
throwsArgumentError);
});
}

View file

@ -0,0 +1,151 @@
// ignore_for_file: avoid_print, empty_catches
import 'dart:convert';
import 'package:background_downloader/background_downloader.dart';
import 'package:background_downloader/src/base_downloader.dart';
import 'package:background_downloader/src/chunk.dart';
import 'package:background_downloader/src/desktop/parallel_download_isolate.dart';
import 'package:flutter_test/flutter_test.dart';
const urlWithContentLength = 'https://storage.googleapis'
'.com/approachcharts/test/5MB-test.ZIP';
const urlWithContentLengthFileSize = 6207471;
void main() {
test('createChunks', () {
// one url, one chunk
var task = ParallelDownloadTask(url: urlWithContentLength);
expect(
() => createChunks(task, {}), throwsA(const TypeMatcher<StateError>()));
expect(() => createChunks(task, {'content-length': '100'}),
throwsA(const TypeMatcher<StateError>()));
expect(() => createChunks(task, {'accept-ranges': 'bytes'}),
throwsA(const TypeMatcher<StateError>()));
expect(
() => createChunks(
task, {'content-length': '-1', 'accept-ranges': 'bytes'}),
throwsA(const TypeMatcher<StateError>()));
var chunks =
createChunks(task, {'content-length': '100', 'accept-ranges': 'bytes'});
expect(chunks.length, equals(1));
var chunk = chunks.first;
expect(chunk.url, equals(task.url));
expect(chunk.filename.isNotEmpty, isTrue);
expect(chunk.fromByte, equals(0));
expect(chunk.toByte, equals(99));
// one url, three chunks
task = ParallelDownloadTask(url: urlWithContentLength, chunks: 3);
chunks =
createChunks(task, {'content-length': '100', 'accept-ranges': 'bytes'});
expect(chunks.length, equals(3));
chunk = chunks.first;
expect(chunk.url, equals(task.url));
expect(chunk.filename.isNotEmpty, isTrue);
expect(chunk.fromByte, equals(0));
expect(chunk.toByte, equals(33));
expect(chunk.parentTaskId, equals(task.taskId));
expect(chunk.task.metaData,
equals('{"parentTaskId":"${task.taskId}","from":0,"to":33}'));
expect(chunk.task.group, equals(BaseDownloader.chunkGroup));
chunk = chunks[1];
expect(chunk.url, equals(task.url));
expect(chunk.filename.isNotEmpty, isTrue);
expect(chunk.fromByte, equals(34));
expect(chunk.toByte, equals(67));
chunk = chunks[2];
expect(chunk.url, equals(task.url));
expect(chunk.filename.isNotEmpty, isTrue);
expect(chunk.fromByte, equals(68));
expect(chunk.toByte, equals(99));
// two urls, two chunks
task = ParallelDownloadTask(
url: [urlWithContentLength, urlWithContentLength], chunks: 2);
chunks =
createChunks(task, {'content-length': '100', 'accept-ranges': 'bytes'});
expect(chunks.length, equals(4));
chunk = chunks.first;
expect(chunk.url, equals(task.urls.first));
expect(chunk.filename.isNotEmpty, isTrue);
expect(chunk.fromByte, equals(0));
expect(chunk.toByte, equals(24));
chunk = chunks[1];
expect(chunk.url, equals(task.urls.last));
expect(chunk.filename.isNotEmpty, isTrue);
expect(chunk.fromByte, equals(25));
expect(chunk.toByte, equals(49));
chunk = chunks[2];
expect(chunk.url, equals(task.urls.first));
expect(chunk.filename.isNotEmpty, isTrue);
expect(chunk.fromByte, equals(50));
expect(chunk.toByte, equals(74));
chunk = chunks[3];
expect(chunk.url, equals(task.urls.last));
expect(chunk.filename.isNotEmpty, isTrue);
expect(chunk.fromByte, equals(75));
expect(chunk.toByte, equals(99));
});
test('updates', () {
var task = ParallelDownloadTask(url: urlWithContentLength, chunks: 3);
parentTask = task;
chunks = createChunks(task, {
'content-length': urlWithContentLengthFileSize.toString(),
'accept-ranges': 'bytes'
});
expect(chunks.length, equals(3));
expect(chunks.first.toByte - chunks.first.fromByte, equals(2069156));
// check progress update
expect(parentTaskProgress(), equals(0.0));
// fake 50% progress on first chunk (of 3)
final progressUpdate =
updateChunkProgress(TaskProgressUpdate(chunks.first.task, 0.5));
expect(progressUpdate, equals(0.5 / 3));
// check status update towards complete
expect(parentTaskStatus(), isNull);
expect(
updateChunkStatus(
TaskStatusUpdate(chunks.first.task, TaskStatus.complete)),
isNull);
expect(
updateChunkStatus(
TaskStatusUpdate(chunks.last.task, TaskStatus.complete)),
isNull);
expect(
updateChunkStatus(
TaskStatusUpdate(chunks[1].task, TaskStatus.complete)),
equals(TaskStatus.complete));
// check failed
for (final chunk in chunks) {
updateChunkStatus(TaskStatusUpdate(chunk.task, TaskStatus.failed));
}
expect(parentTaskStatus(), equals(TaskStatus.failed));
// check notFound
for (final chunk in chunks) {
updateChunkStatus(TaskStatusUpdate(chunk.task, TaskStatus.notFound));
}
expect(parentTaskStatus(), equals(TaskStatus.notFound));
});
test('json chunks', () {
var task = ParallelDownloadTask(url: urlWithContentLength, chunks: 3);
chunks = createChunks(task, {
'content-length': urlWithContentLengthFileSize.toString(),
'accept-ranges': 'bytes'
});
final chunksJson = jsonEncode(chunks);
final List<Chunk> decodedChunks =
List.from(jsonDecode(chunksJson, reviver: Chunk.listReviver));
for (var i = 0; i < chunks.length; i++) {
expect(chunks[i].parentTaskId, equals(decodedChunks[i].parentTaskId));
expect(chunks[i].url, equals(decodedChunks[i].url));
expect(chunks[i].filename, equals(decodedChunks[i].filename));
expect(chunks[i].fromByte, equals(decodedChunks[i].fromByte));
expect(chunks[i].toByte, equals(decodedChunks[i].toByte));
expect(chunks[i].task, equals(decodedChunks[i].task));
expect(chunks[i].task.metaData, equals(decodedChunks[i].task.metaData));
expect(chunks[i].status, equals(decodedChunks[i].status));
expect(chunks[i].progress, equals(decodedChunks[i].progress));
}
});
}

View file

@ -0,0 +1,215 @@
// ignore_for_file: avoid_print
import 'dart:math';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
final class TestTaskQueue extends MemoryTaskQueue {
double probFailure = 0;
@override
Future<bool> enqueue(Task task) async {
debugPrint('${task.taskId} - enqueueing');
Future.delayed(const Duration(milliseconds: 200));
debugPrint('${task.taskId} - enqueued');
Future.delayed(const Duration(seconds: 4)).then((_) {
// complete the task after 4 seconds
debugPrint('${task.taskId} - finished');
taskFinished(task);
debugPrint('Remaining tasks: ${waiting.length}, $numActive active');
});
return Random().nextDouble() > probFailure;
}
}
const workingUrl = 'https://google.com';
void main() {
WidgetsFlutterBinding.ensureInitialized();
final tq = TestTaskQueue();
var task = DownloadTask(url: 'testUrl');
setUp(() {
tq.probFailure = 0;
tq.maxConcurrent = 10000000;
tq.minInterval = const Duration(milliseconds: 550);
tq.reset();
});
group('Add to queue', () {
test('add', () async {
expect(tq.isEmpty, isTrue);
tq.add(task);
await Future.delayed(const Duration(seconds: 5));
expect(tq.isEmpty, isTrue);
});
test('add multiple', () async {
expect(tq.isEmpty, isTrue);
for (var n = 0; n < 10; n++) {
task = DownloadTask(taskId: '$n', url: 'testUrl');
tq.add(task);
}
expect(tq.isEmpty, isFalse);
await Future.delayed(const Duration(seconds: 10));
expect(tq.isEmpty, isTrue);
tq.add(DownloadTask(url: 'testUrl'));
expect(tq.isEmpty, isTrue);
await Future.delayed(const Duration(seconds: 5));
expect(tq.isEmpty, isTrue);
});
test('addAll', () async {
expect(tq.isEmpty, isTrue);
final tasks = <Task>[];
for (var n = 0; n < 10; n++) {
task = DownloadTask(taskId: '$n', url: 'testUrl');
tasks.add(task);
}
tq.addAll(tasks);
expect(tq.isEmpty, isFalse);
await Future.delayed(const Duration(seconds: 10));
expect(tq.isEmpty, isTrue);
});
});
group('Concurrent', () {
test('maxConcurrent', () async {
tq.maxConcurrent = 2;
expect(tq.isEmpty, isTrue);
for (var n = 0; n < 10; n++) {
task = DownloadTask(taskId: '$n', url: 'testUrl');
tq.add(task);
}
expect(tq.isEmpty, isFalse);
await Future.delayed(const Duration(seconds: 10));
expect(tq.isEmpty, isFalse);
expect(tq.waiting.length, greaterThan(0));
await Future.delayed(const Duration(seconds: 20));
expect(tq.isEmpty, isTrue);
});
test('numActiveWithHostname', () async {
expect(() => DownloadTask(url: '::invalid::').hostName,
throwsFormatException);
expect(DownloadTask(url: 'empty').hostName, equals(''));
expect(DownloadTask(url: workingUrl).hostName, equals('google.com'));
task = DownloadTask(taskId: '1', url: workingUrl);
tq.add(task);
expect(tq.numActive, equals(1));
expect(tq.numActiveWithHostname('google.com'), equals(1));
expect(tq.numActiveWithHostname('somethingElse.com'), equals(0));
await Future.delayed(const Duration(seconds: 5));
expect(tq.isEmpty, isTrue);
});
test('maxConcurrentByHost', () async {
tq.maxConcurrentByHost = 2;
expect(tq.isEmpty, isTrue);
for (var n = 0; n < 10; n++) {
task = DownloadTask(taskId: '$n', url: workingUrl);
tq.add(task);
}
expect(tq.isEmpty, isFalse);
await Future.delayed(const Duration(seconds: 10));
expect(tq.isEmpty, isFalse);
expect(tq.waiting.length, greaterThan(0));
await Future.delayed(const Duration(seconds: 20));
expect(tq.isEmpty, isTrue);
});
test('numActiveWithGroup', () async {
task = DownloadTask(taskId: '1', url: workingUrl);
tq.add(task);
expect(tq.numActive, equals(1));
expect(tq.numActiveWithGroup('default'), equals(1));
expect(tq.numActiveWithGroup('other'), equals(0));
await Future.delayed(const Duration(seconds: 5));
expect(tq.isEmpty, isTrue);
});
test('maxConcurrentByGroup', () async {
tq.maxConcurrentByGroup = 2;
expect(tq.isEmpty, isTrue);
for (var n = 0; n < 10; n++) {
task = DownloadTask(taskId: '$n', url: workingUrl);
tq.add(task);
}
expect(tq.isEmpty, isFalse);
await Future.delayed(const Duration(seconds: 10));
expect(tq.isEmpty, isFalse);
expect(tq.waiting.length, greaterThan(0));
await Future.delayed(const Duration(seconds: 20));
expect(tq.isEmpty, isTrue);
});
test('combine maxConcurrent with limit from ByHost', () async {
// we load only two urls, so the maxConcurrentByHost is going to be
// the limiting factor
tq.maxConcurrentByHost = 2;
tq.maxConcurrent = 5;
expect(tq.isEmpty, isTrue);
for (var n = 0; n < 10; n++) {
task = DownloadTask(taskId: 'google-$n', url: workingUrl);
tq.add(task);
}
for (var n = 0; n < 10; n++) {
task = DownloadTask(taskId: 'other-$n', url: 'http://netflix.com');
tq.add(task);
}
expect(tq.isEmpty, isFalse);
await Future.delayed(const Duration(seconds: 10));
expect(tq.isEmpty, isFalse);
expect(tq.waiting.length, greaterThan(0));
await Future.delayed(const Duration(seconds: 20));
expect(tq.isEmpty, isTrue);
});
test('combine maxConcurrent without limit from ByHost', () async {
// now we load only multiple urls, so the maxConcurrentByHost is not
// going to be the limiting factor
tq.maxConcurrentByHost = 2;
tq.maxConcurrent = 5;
expect(tq.isEmpty, isTrue);
for (var n = 0; n < 10; n++) {
task = DownloadTask(taskId: 'google-$n', url: workingUrl);
tq.add(task);
}
for (var n = 0; n < 10; n++) {
// different url for each
task = DownloadTask(taskId: 'other-$n', url: 'http://netflix$n.com');
tq.add(task);
}
expect(tq.isEmpty, isFalse);
await Future.delayed(const Duration(seconds: 10));
expect(tq.isEmpty, isFalse);
expect(tq.waiting.length, greaterThan(0));
await Future.delayed(const Duration(seconds: 20));
expect(tq.isEmpty, isTrue);
});
});
group('errors', () {
test('enqueueErrors', () async {
tq.probFailure = 0.8; // 80% failure rate
var errorCount = 0;
tq.enqueueErrors.listen((task) {
errorCount += 1;
print('${task.taskId} failed to enqueue');
});
expect(tq.isEmpty, isTrue);
for (var n = 0; n < 10; n++) {
task = DownloadTask(taskId: '$n', url: 'testUrl');
tq.add(task);
}
expect(tq.isEmpty, isFalse);
await Future.delayed(const Duration(seconds: 10));
expect(tq.isEmpty, isTrue);
print('$errorCount enqueue errors');
expect(errorCount, greaterThan(3));
});
});
}

View file

@ -60,11 +60,10 @@ packages:
background_downloader:
dependency: "direct main"
description:
name: background_downloader
sha256: "4e1722c4002c6c7aef2305b580f4ffe410582452aa980e6e69bad77f68bc26c0"
url: "https://pub.dev"
source: hosted
version: "7.11.1"
path: "packages/background_downloader"
relative: true
source: path
version: "7.12.3"
boolean_selector:
dependency: transitive
description:

View file

@ -29,7 +29,8 @@ dependencies:
google_fonts: ^5.1.0
url_launcher: ^6.1.12
package_info_plus: ^4.1.0
background_downloader: ^7.11.1
background_downloader:
path: packages/background_downloader
permission_handler: ^11.0.1
flutter_inappwebview: ^5.7.2+3
draggable_menu: ^4.4.0