diff --git a/android/app/build.gradle b/android/app/build.gradle index 5be9a51..da765dd 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -30,16 +30,16 @@ if (flutterVersionName == null) { android { namespace "com.kodjodevf.mangayomi" - compileSdkVersion 34 + compileSdkVersion 35 ndkVersion flutter.ndkVersion compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = '1.8' + jvmTarget = JavaVersion.VERSION_17 } sourceSets { @@ -53,9 +53,9 @@ android { // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. minSdkVersion 21 - targetSdkVersion flutter.targetSdkVersion - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName } signingConfigs { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 758e794..d5817e5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -398,5 +398,6 @@ "app_settings": "App settings", "sources_settings": "Sources settings", "include_sensitive_settings": "Include sensitive settings (e.g., tracker login tokens)", - "create": "Create" + "create": "Create", + "downloads_are_limited_to_wifi": "Downloads are limited to Wi-Fi only" } \ No newline at end of file diff --git a/lib/models/download.dart b/lib/models/download.dart index 48062e5..0d72c63 100644 --- a/lib/models/download.dart +++ b/lib/models/download.dart @@ -8,10 +8,6 @@ part 'download.g.dart'; class Download { Id? id; - int? chapterId; - - int? mangaId; - int? succeeded; int? failed; @@ -20,44 +16,33 @@ class Download { bool? isDownload; - List? taskIds; - bool? isStartDownload; final chapter = IsarLink(); Download({ - this.id = Isar.autoIncrement, - required this.chapterId, - required this.mangaId, + this.id = 0, required this.succeeded, required this.failed, required this.total, required this.isDownload, - required this.taskIds, required this.isStartDownload, }); Download.fromJson(Map json) { - chapterId = json['chapterId']; failed = json['failed']; id = json['id']; isDownload = json['isDownload']; isStartDownload = json['isStartDownload']; - mangaId = json['mangaId']; succeeded = json['succeeded']; - taskIds = json['taskIds'].cast(); total = json['total']; } Map toJson() => { - 'chapterId': chapterId, 'failed': failed, 'id': id, 'isDownload': isDownload, 'isStartDownload': isStartDownload, - 'mangaId': mangaId, 'succeeded': succeeded, - 'taskIds': taskIds, 'total': total }; } diff --git a/lib/models/download.g.dart b/lib/models/download.g.dart index bc56f8c..1ad51ed 100644 --- a/lib/models/download.g.dart +++ b/lib/models/download.g.dart @@ -17,43 +17,28 @@ const DownloadSchema = CollectionSchema( name: r'Download', id: 5905484153212786579, properties: { - r'chapterId': PropertySchema( - id: 0, - name: r'chapterId', - type: IsarType.long, - ), r'failed': PropertySchema( - id: 1, + id: 0, name: r'failed', type: IsarType.long, ), r'isDownload': PropertySchema( - id: 2, + id: 1, name: r'isDownload', type: IsarType.bool, ), r'isStartDownload': PropertySchema( - id: 3, + id: 2, name: r'isStartDownload', type: IsarType.bool, ), - r'mangaId': PropertySchema( - id: 4, - name: r'mangaId', - type: IsarType.long, - ), r'succeeded': PropertySchema( - id: 5, + id: 3, name: r'succeeded', type: IsarType.long, ), - r'taskIds': PropertySchema( - id: 6, - name: r'taskIds', - type: IsarType.stringList, - ), r'total': PropertySchema( - id: 7, + id: 4, name: r'total', type: IsarType.long, ) @@ -85,18 +70,6 @@ int _downloadEstimateSize( Map> allOffsets, ) { var bytesCount = offsets.last; - { - final list = object.taskIds; - if (list != null) { - bytesCount += 3 + list.length * 3; - { - for (var i = 0; i < list.length; i++) { - final value = list[i]; - bytesCount += value.length * 3; - } - } - } - } return bytesCount; } @@ -106,14 +79,11 @@ void _downloadSerialize( List offsets, Map> allOffsets, ) { - writer.writeLong(offsets[0], object.chapterId); - writer.writeLong(offsets[1], object.failed); - writer.writeBool(offsets[2], object.isDownload); - writer.writeBool(offsets[3], object.isStartDownload); - writer.writeLong(offsets[4], object.mangaId); - writer.writeLong(offsets[5], object.succeeded); - writer.writeStringList(offsets[6], object.taskIds); - writer.writeLong(offsets[7], object.total); + writer.writeLong(offsets[0], object.failed); + writer.writeBool(offsets[1], object.isDownload); + writer.writeBool(offsets[2], object.isStartDownload); + writer.writeLong(offsets[3], object.succeeded); + writer.writeLong(offsets[4], object.total); } Download _downloadDeserialize( @@ -123,15 +93,12 @@ Download _downloadDeserialize( Map> allOffsets, ) { final object = Download( - chapterId: reader.readLongOrNull(offsets[0]), - failed: reader.readLongOrNull(offsets[1]), + failed: reader.readLongOrNull(offsets[0]), id: id, - isDownload: reader.readBoolOrNull(offsets[2]), - isStartDownload: reader.readBoolOrNull(offsets[3]), - mangaId: reader.readLongOrNull(offsets[4]), - succeeded: reader.readLongOrNull(offsets[5]), - taskIds: reader.readStringList(offsets[6]), - total: reader.readLongOrNull(offsets[7]), + isDownload: reader.readBoolOrNull(offsets[1]), + isStartDownload: reader.readBoolOrNull(offsets[2]), + succeeded: reader.readLongOrNull(offsets[3]), + total: reader.readLongOrNull(offsets[4]), ); return object; } @@ -146,19 +113,13 @@ P _downloadDeserializeProp

( case 0: return (reader.readLongOrNull(offset)) as P; case 1: - return (reader.readLongOrNull(offset)) as P; + return (reader.readBoolOrNull(offset)) as P; case 2: return (reader.readBoolOrNull(offset)) as P; case 3: - return (reader.readBoolOrNull(offset)) as P; + return (reader.readLongOrNull(offset)) as P; case 4: return (reader.readLongOrNull(offset)) as P; - case 5: - return (reader.readLongOrNull(offset)) as P; - case 6: - return (reader.readStringList(offset)) as P; - case 7: - return (reader.readLongOrNull(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); } @@ -254,75 +215,6 @@ extension DownloadQueryWhere on QueryBuilder { extension DownloadQueryFilter on QueryBuilder { - QueryBuilder chapterIdIsNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNull( - property: r'chapterId', - )); - }); - } - - QueryBuilder chapterIdIsNotNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNotNull( - property: r'chapterId', - )); - }); - } - - QueryBuilder chapterIdEqualTo( - int? value) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'chapterId', - value: value, - )); - }); - } - - QueryBuilder chapterIdGreaterThan( - int? value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'chapterId', - value: value, - )); - }); - } - - QueryBuilder chapterIdLessThan( - int? value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'chapterId', - value: value, - )); - }); - } - - QueryBuilder chapterIdBetween( - int? lower, - int? upper, { - bool includeLower = true, - bool includeUpper = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'chapterId', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - )); - }); - } - QueryBuilder failedIsNull() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(const FilterCondition.isNull( @@ -515,75 +407,6 @@ extension DownloadQueryFilter }); } - QueryBuilder mangaIdIsNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNull( - property: r'mangaId', - )); - }); - } - - QueryBuilder mangaIdIsNotNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNotNull( - property: r'mangaId', - )); - }); - } - - QueryBuilder mangaIdEqualTo( - int? value) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'mangaId', - value: value, - )); - }); - } - - QueryBuilder mangaIdGreaterThan( - int? value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'mangaId', - value: value, - )); - }); - } - - QueryBuilder mangaIdLessThan( - int? value, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'mangaId', - value: value, - )); - }); - } - - QueryBuilder mangaIdBetween( - int? lower, - int? upper, { - bool includeLower = true, - bool includeUpper = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'mangaId', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - )); - }); - } - QueryBuilder succeededIsNull() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(const FilterCondition.isNull( @@ -653,242 +476,6 @@ extension DownloadQueryFilter }); } - QueryBuilder taskIdsIsNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNull( - property: r'taskIds', - )); - }); - } - - QueryBuilder taskIdsIsNotNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNotNull( - property: r'taskIds', - )); - }); - } - - QueryBuilder taskIdsElementEqualTo( - String value, { - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'taskIds', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder - taskIdsElementGreaterThan( - String value, { - bool include = false, - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - include: include, - property: r'taskIds', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder - taskIdsElementLessThan( - String value, { - bool include = false, - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.lessThan( - include: include, - property: r'taskIds', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder taskIdsElementBetween( - String lower, - String upper, { - bool includeLower = true, - bool includeUpper = true, - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.between( - property: r'taskIds', - lower: lower, - includeLower: includeLower, - upper: upper, - includeUpper: includeUpper, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder - taskIdsElementStartsWith( - String value, { - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.startsWith( - property: r'taskIds', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder - taskIdsElementEndsWith( - String value, { - bool caseSensitive = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.endsWith( - property: r'taskIds', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder - taskIdsElementContains(String value, {bool caseSensitive = true}) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.contains( - property: r'taskIds', - value: value, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder taskIdsElementMatches( - String pattern, - {bool caseSensitive = true}) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.matches( - property: r'taskIds', - wildcard: pattern, - caseSensitive: caseSensitive, - )); - }); - } - - QueryBuilder - taskIdsElementIsEmpty() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'taskIds', - value: '', - )); - }); - } - - QueryBuilder - taskIdsElementIsNotEmpty() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.greaterThan( - property: r'taskIds', - value: '', - )); - }); - } - - QueryBuilder taskIdsLengthEqualTo( - int length) { - return QueryBuilder.apply(this, (query) { - return query.listLength( - r'taskIds', - length, - true, - length, - true, - ); - }); - } - - QueryBuilder taskIdsIsEmpty() { - return QueryBuilder.apply(this, (query) { - return query.listLength( - r'taskIds', - 0, - true, - 0, - true, - ); - }); - } - - QueryBuilder taskIdsIsNotEmpty() { - return QueryBuilder.apply(this, (query) { - return query.listLength( - r'taskIds', - 0, - false, - 999999, - true, - ); - }); - } - - QueryBuilder taskIdsLengthLessThan( - int length, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.listLength( - r'taskIds', - 0, - true, - length, - include, - ); - }); - } - - QueryBuilder - taskIdsLengthGreaterThan( - int length, { - bool include = false, - }) { - return QueryBuilder.apply(this, (query) { - return query.listLength( - r'taskIds', - length, - include, - 999999, - true, - ); - }); - } - - QueryBuilder taskIdsLengthBetween( - int lower, - int upper, { - bool includeLower = true, - bool includeUpper = true, - }) { - return QueryBuilder.apply(this, (query) { - return query.listLength( - r'taskIds', - lower, - includeLower, - upper, - includeUpper, - ); - }); - } - QueryBuilder totalIsNull() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(const FilterCondition.isNull( @@ -979,18 +566,6 @@ extension DownloadQueryLinks } extension DownloadQuerySortBy on QueryBuilder { - QueryBuilder sortByChapterId() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'chapterId', Sort.asc); - }); - } - - QueryBuilder sortByChapterIdDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'chapterId', Sort.desc); - }); - } - QueryBuilder sortByFailed() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'failed', Sort.asc); @@ -1027,18 +602,6 @@ extension DownloadQuerySortBy on QueryBuilder { }); } - QueryBuilder sortByMangaId() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'mangaId', Sort.asc); - }); - } - - QueryBuilder sortByMangaIdDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'mangaId', Sort.desc); - }); - } - QueryBuilder sortBySucceeded() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'succeeded', Sort.asc); @@ -1066,18 +629,6 @@ extension DownloadQuerySortBy on QueryBuilder { extension DownloadQuerySortThenBy on QueryBuilder { - QueryBuilder thenByChapterId() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'chapterId', Sort.asc); - }); - } - - QueryBuilder thenByChapterIdDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'chapterId', Sort.desc); - }); - } - QueryBuilder thenByFailed() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'failed', Sort.asc); @@ -1126,18 +677,6 @@ extension DownloadQuerySortThenBy }); } - QueryBuilder thenByMangaId() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'mangaId', Sort.asc); - }); - } - - QueryBuilder thenByMangaIdDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'mangaId', Sort.desc); - }); - } - QueryBuilder thenBySucceeded() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'succeeded', Sort.asc); @@ -1165,12 +704,6 @@ extension DownloadQuerySortThenBy extension DownloadQueryWhereDistinct on QueryBuilder { - QueryBuilder distinctByChapterId() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'chapterId'); - }); - } - QueryBuilder distinctByFailed() { return QueryBuilder.apply(this, (query) { return query.addDistinctBy(r'failed'); @@ -1189,24 +722,12 @@ extension DownloadQueryWhereDistinct }); } - QueryBuilder distinctByMangaId() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'mangaId'); - }); - } - QueryBuilder distinctBySucceeded() { return QueryBuilder.apply(this, (query) { return query.addDistinctBy(r'succeeded'); }); } - QueryBuilder distinctByTaskIds() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'taskIds'); - }); - } - QueryBuilder distinctByTotal() { return QueryBuilder.apply(this, (query) { return query.addDistinctBy(r'total'); @@ -1222,12 +743,6 @@ extension DownloadQueryProperty }); } - QueryBuilder chapterIdProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'chapterId'); - }); - } - QueryBuilder failedProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'failed'); @@ -1246,24 +761,12 @@ extension DownloadQueryProperty }); } - QueryBuilder mangaIdProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'mangaId'); - }); - } - QueryBuilder succeededProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'succeeded'); }); } - QueryBuilder?, QQueryOperations> taskIdsProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'taskIds'); - }); - } - QueryBuilder totalProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'total'); diff --git a/lib/models/page.dart b/lib/models/page.dart index ed3995a..6d7ecda 100644 --- a/lib/models/page.dart +++ b/lib/models/page.dart @@ -2,9 +2,10 @@ import 'package:mangayomi/eval/javascript/http.dart'; class PageUrl { String url; + String? fileName; Map? headers; - PageUrl(this.url, {this.headers}); + PageUrl(this.url, {this.fileName, this.headers}); factory PageUrl.fromJson(Map json) { return PageUrl( json['url'].toString().trim(), @@ -12,4 +13,9 @@ class PageUrl { ); } Map toJson() => {'url': url, 'headers': headers}; + + @override + String toString() { + return 'PageUrl(url: $url, headers: $headers, fileName: $fileName)'; + } } diff --git a/lib/modules/library/library_screen.dart b/lib/modules/library/library_screen.dart index cda2088..15402ae 100644 --- a/lib/modules/library/library_screen.dart +++ b/lib/modules/library/library_screen.dart @@ -812,11 +812,8 @@ class _LibraryScreenState extends ConsumerState List list = []; if (downloadFilterType == 1) { for (var chap in element.chapters) { - final modelChapDownload = isar.downloads - .filter() - .idIsNotNull() - .chapterIdEqualTo(chap.id) - .findAllSync(); + final modelChapDownload = + isar.downloads.filter().idEqualTo(chap.id).findAllSync(); if (modelChapDownload.isNotEmpty && modelChapDownload.first.isDownload == true) { @@ -826,11 +823,8 @@ class _LibraryScreenState extends ConsumerState return list.isNotEmpty; } else if (downloadFilterType == 2) { for (var chap in element.chapters) { - final modelChapDownload = isar.downloads - .filter() - .idIsNotNull() - .chapterIdEqualTo(chap.id) - .findAllSync(); + final modelChapDownload = + isar.downloads.filter().idEqualTo(chap.id).findAllSync(); if (!(modelChapDownload.isNotEmpty && modelChapDownload.first.isDownload == true)) { list.add(true); @@ -1214,7 +1208,7 @@ class _LibraryScreenState extends ConsumerState isar.writeTxnSync(() { final download = isar.downloads .filter() - .chapterIdEqualTo(chapter.id!) + .idEqualTo(chapter.id!) .findAllSync(); if (download.isNotEmpty) { isar.downloads.deleteSync( diff --git a/lib/modules/library/widgets/library_gridview_widget.dart b/lib/modules/library/widgets/library_gridview_widget.dart index c7bac6a..195d3ad 100644 --- a/lib/modules/library/widgets/library_gridview_widget.dart +++ b/lib/modules/library/widgets/library_gridview_widget.dart @@ -170,9 +170,7 @@ class _LibraryGridViewWidgetState extends State { i++) { final entries = isar.downloads .filter() - .idIsNotNull() - .chapterIdEqualTo(entry - .chapters + .idEqualTo(entry.chapters .toList()[i] .id) .findAllSync(); diff --git a/lib/modules/library/widgets/library_listview_widget.dart b/lib/modules/library/widgets/library_listview_widget.dart index 4c12893..a1f1102 100644 --- a/lib/modules/library/widgets/library_listview_widget.dart +++ b/lib/modules/library/widgets/library_listview_widget.dart @@ -180,9 +180,7 @@ class LibraryListViewWidget extends StatelessWidget { i++) { final entries = isar.downloads .filter() - .idIsNotNull() - .chapterIdEqualTo(entry - .chapters + .idEqualTo(entry.chapters .toList()[i] .id) .findAllSync(); diff --git a/lib/modules/manga/detail/manga_detail_view.dart b/lib/modules/manga/detail/manga_detail_view.dart index 1c88f77..8a30bcb 100644 --- a/lib/modules/manga/detail/manga_detail_view.dart +++ b/lib/modules/manga/detail/manga_detail_view.dart @@ -168,11 +168,8 @@ class _MangaDetailViewState extends ConsumerState ? element.isBookmarked == false : true) .where((element) { - final modelChapDownload = isar.downloads - .filter() - .idIsNotNull() - .chapterIdEqualTo(element.id) - .findAllSync(); + final modelChapDownload = + isar.downloads.filter().idEqualTo(element.id).findAllSync(); return filterDownloaded == 1 ? modelChapDownload.isNotEmpty && modelChapDownload.first.isDownload == true @@ -432,8 +429,7 @@ class _MangaDetailViewState extends ConsumerState final chapter = chapters.first; final entry = isar.downloads .filter() - .idIsNotNull() - .chapterIdEqualTo(chapter.id) + .idEqualTo(chapter.id) .findFirstSync(); if (entry == null || !entry.isDownload!) { @@ -457,8 +453,7 @@ class _MangaDetailViewState extends ConsumerState lastChapterReadIndex + i]; final entry = isar.downloads .filter() - .idIsNotNull() - .chapterIdEqualTo(chapter.id) + .idEqualTo(chapter.id) .findFirstSync(); if (entry == null || !entry.isDownload!) { @@ -479,8 +474,7 @@ class _MangaDetailViewState extends ConsumerState for (var chapter in unreadChapters) { final entry = isar.downloads .filter() - .idIsNotNull() - .chapterIdEqualTo(chapter.id) + .idEqualTo(chapter.id) .findFirstSync(); if (entry == null || !entry.isDownload!) { @@ -866,8 +860,7 @@ class _MangaDetailViewState extends ConsumerState in ref.watch(chaptersListStateProvider)) { final entries = isar.downloads .filter() - .idIsNotNull() - .chapterIdEqualTo(chapter.id) + .idEqualTo(chapter.id) .findAllSync(); if (entries.isEmpty || !entries.first.isDownload!) { diff --git a/lib/modules/manga/detail/providers/state_providers.dart b/lib/modules/manga/detail/providers/state_providers.dart index 7f07165..bee226b 100644 --- a/lib/modules/manga/detail/providers/state_providers.dart +++ b/lib/modules/manga/detail/providers/state_providers.dart @@ -346,11 +346,8 @@ class ChapterSetDownloadState extends _$ChapterSetDownloadState { ref.read(isLongPressedStateProvider.notifier).update(false); isar.txnSync(() { for (var chapter in ref.watch(chaptersListStateProvider)) { - final entries = isar.downloads - .filter() - .idIsNotNull() - .chapterIdEqualTo(chapter.id) - .findAllSync(); + final entries = + isar.downloads.filter().idEqualTo(chapter.id).findAllSync(); if (entries.isEmpty || !entries.first.isDownload!) { ref.watch(downloadChapterProvider(chapter: chapter)); } diff --git a/lib/modules/manga/detail/providers/state_providers.g.dart b/lib/modules/manga/detail/providers/state_providers.g.dart index 289dce7..198cee9 100644 --- a/lib/modules/manga/detail/providers/state_providers.g.dart +++ b/lib/modules/manga/detail/providers/state_providers.g.dart @@ -1110,7 +1110,7 @@ class _ChapterSetIsReadStateProviderElement } String _$chapterSetDownloadStateHash() => - r'496b93306bd41686daf09af7f7594ae697927005'; + r'321f00669a4644016076dcf5e007355d696d26e3'; abstract class _$ChapterSetDownloadState extends BuildlessAutoDisposeNotifier { diff --git a/lib/modules/manga/download/download_page_widget.dart b/lib/modules/manga/download/download_page_widget.dart index d2fe76c..43dc0c2 100644 --- a/lib/modules/manga/download/download_page_widget.dart +++ b/lib/modules/manga/download/download_page_widget.dart @@ -6,17 +6,16 @@ import 'package:isar/isar.dart'; import 'package:mangayomi/main.dart'; import 'package:mangayomi/models/chapter.dart'; import 'package:mangayomi/models/download.dart'; -import 'package:mangayomi/models/settings.dart'; import 'package:mangayomi/providers/l10n_providers.dart'; import 'package:mangayomi/providers/storage_provider.dart'; import 'package:mangayomi/modules/manga/download/providers/download_provider.dart'; -import 'package:mangayomi/services/background_downloader/background_downloader.dart'; +import 'package:mangayomi/utils/extensions/chapter.dart'; import 'package:mangayomi/utils/extensions/string_extensions.dart'; import 'package:mangayomi/utils/global_style.dart'; import 'package:share_plus/share_plus.dart'; import 'package:path/path.dart' as p; -class ChapterPageDownload extends ConsumerStatefulWidget { +class ChapterPageDownload extends ConsumerWidget { final Chapter chapter; const ChapterPageDownload({ @@ -24,36 +23,22 @@ class ChapterPageDownload extends ConsumerStatefulWidget { required this.chapter, }); - @override - ConsumerState createState() => _ChapterPageDownloadState(); -} - -class _ChapterPageDownloadState extends ConsumerState - with AutomaticKeepAliveClientMixin { - List _pageUrls = []; - - final StorageProvider _storageProvider = StorageProvider(); - - void _startDownload(bool? useWifi) async { - await ref.watch( - downloadChapterProvider(chapter: widget.chapter, useWifi: useWifi) - .future); + void _startDownload(bool? useWifi, int? downloadId, WidgetRef ref) async { + _cancelTasks(downloadId: downloadId); + ref.read(downloadChapterProvider(chapter: chapter, useWifi: useWifi)); } - late final manga = widget.chapter.manga.value!; - void _sendFile() async { - final mangaDir = - await _storageProvider.getMangaMainDirectory(widget.chapter); - final path = - await _storageProvider.getMangaChapterDirectory(widget.chapter); + final storageProvider = StorageProvider(); + final mangaDir = await storageProvider.getMangaMainDirectory(chapter); + final path = await storageProvider.getMangaChapterDirectory(chapter); List files = []; - final cbzFile = File(p.join(mangaDir!.path, "${widget.chapter.name}.cbz")); - final mp4File = File(p.join(mangaDir.path, - "${widget.chapter.name!.replaceForbiddenCharacters(' ')}.mp4")); - final htmlFile = File(p.join(mangaDir.path, "${widget.chapter.name}.html")); + final cbzFile = File(p.join(mangaDir!.path, "${chapter.name}.cbz")); + final mp4File = File(p.join( + mangaDir.path, "${chapter.name!.replaceForbiddenCharacters(' ')}.mp4")); + final htmlFile = File(p.join(mangaDir.path, "${chapter.name}.html")); if (cbzFile.existsSync()) { files = [XFile(cbzFile.path)]; } else if (mp4File.existsSync()) { @@ -64,55 +49,43 @@ class _ChapterPageDownloadState extends ConsumerState files = path!.listSync().map((e) => XFile(e.path)).toList(); } if (files.isNotEmpty) { - Share.shareXFiles(files, text: widget.chapter.name); + Share.shareXFiles(files, text: chapter.name); } } - void _deleteFile() async { - final mangaDir = - await _storageProvider.getMangaMainDirectory(widget.chapter); - final path = - await _storageProvider.getMangaChapterDirectory(widget.chapter); + void _deleteFile(int downloadId) async { + final storageProvider = StorageProvider(); + final mangaDir = await storageProvider.getMangaMainDirectory(chapter); + final path = await storageProvider.getMangaChapterDirectory(chapter); try { try { - final cbzFile = - File(p.join(mangaDir!.path, "${widget.chapter.name}.cbz")); + final cbzFile = File(p.join(mangaDir!.path, "${chapter.name}.cbz")); if (cbzFile.existsSync()) { cbzFile.deleteSync(); } } catch (_) {} try { final mp4File = File(p.join(mangaDir!.path, - "${widget.chapter.name!.replaceForbiddenCharacters(' ')}.mp4")); + "${chapter.name!.replaceForbiddenCharacters(' ')}.mp4")); if (mp4File.existsSync()) { mp4File.deleteSync(); } } catch (_) {} try { - final htmlFile = - File(p.join(mangaDir!.path, "${widget.chapter.name}.html")); + final htmlFile = File(p.join(mangaDir!.path, "${chapter.name}.html")); if (htmlFile.existsSync()) { htmlFile.deleteSync(); } } catch (_) {} path!.deleteSync(recursive: true); } catch (_) {} - isar.writeTxnSync(() { - int id = isar.downloads - .filter() - .chapterIdEqualTo(widget.chapter.id!) - .findFirstSync()! - .id!; - isar.downloads.deleteSync(id); - }); + chapter.cancelDownloads(downloadId); } - bool _isStarted = false; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final l10n = l10nLocalizations(context)!; - super.build(context); return SizedBox( height: 41, width: 35, @@ -121,14 +94,13 @@ class _ChapterPageDownloadState extends ConsumerState child: StreamBuilder( stream: isar.downloads .filter() - .idIsNotNull() - .and() - .chapterIdEqualTo(widget.chapter.id) + .idEqualTo(chapter.id) .watch(fireImmediately: true), builder: (context, snapshot) { if (snapshot.hasData && snapshot.data!.isNotEmpty) { final entries = snapshot.data!; - return entries.first.isDownload! + final download = entries.first; + return download.isDownload! ? PopupMenuButton( popUpAnimationStyle: popupAnimationStyle, child: Icon( @@ -143,7 +115,7 @@ class _ChapterPageDownloadState extends ConsumerState if (value == 0) { _sendFile(); } else if (value == 1) { - _deleteFile(); + _deleteFile(download.id!); } }, itemBuilder: (context) => [ @@ -151,8 +123,7 @@ class _ChapterPageDownloadState extends ConsumerState PopupMenuItem(value: 1, child: Text(l10n.delete)), ], ) - : entries.first.isStartDownload! && - entries.first.succeeded == 0 + : download.isStartDownload! && download.succeeded == 0 ? SizedBox( height: 41, width: 35, @@ -161,13 +132,9 @@ class _ChapterPageDownloadState extends ConsumerState child: _downloadWidget(context, true), onSelected: (value) { if (value == 0) { - _cancelTasks(); - } - if (value == 0) { - _cancelTasks(); + _cancelTasks(downloadId: download.id!); } else if (value == 1) { - _cancelTasks(); - _startDownload(false); + _startDownload(false, download.id, ref); } }, itemBuilder: (context) => [ @@ -177,7 +144,7 @@ class _ChapterPageDownloadState extends ConsumerState PopupMenuItem(value: 0, child: Text(l10n.cancel)), ], )) - : entries.first.succeeded != 0 + : download.succeeded != 0 ? SizedBox( height: 41, width: 35, @@ -193,8 +160,8 @@ class _ChapterPageDownloadState extends ConsumerState curve: Curves.easeInOut, tween: Tween( begin: 0, - end: (entries.first.succeeded! / - entries.first.total!), + end: (download.succeeded! / + download.total!), ), builder: (context, value, _) => SizedBox( @@ -215,8 +182,8 @@ class _ChapterPageDownloadState extends ConsumerState alignment: Alignment.center, child: Icon( Icons.arrow_downward_sharp, - color: (entries.first.succeeded! / - entries.first.total!) > + color: (download.succeeded! / + download.total!) > 0.5 ? Theme.of(context) .scaffoldBackgroundColor @@ -229,13 +196,9 @@ class _ChapterPageDownloadState extends ConsumerState ), onSelected: (value) { if (value == 0) { - _cancelTasks(); - } - if (value == 0) { - _cancelTasks(); + _cancelTasks(downloadId: download.id!); } else if (value == 1) { - _cancelTasks(); - _startDownload(false); + _startDownload(false, download.id, ref); } }, itemBuilder: (context) => [ @@ -246,13 +209,10 @@ class _ChapterPageDownloadState extends ConsumerState value: 0, child: Text(l10n.cancel)), ], )) - : entries.first.succeeded == 0 + : download.succeeded == 0 ? IconButton( onPressed: () { - // _startDownload(); - setState(() { - _isStarted = true; - }); + _startDownload(null, download.id, ref); }, icon: Icon( FontAwesomeIcons.circleDown, @@ -274,11 +234,7 @@ class _ChapterPageDownloadState extends ConsumerState ), onSelected: (value) { if (value == 0) { - _cancelTasks(); - _startDownload(null); - setState(() { - _isStarted = true; - }); + _startDownload(null, download.id, ref); } }, itemBuilder: (context) => [ @@ -287,71 +243,23 @@ class _ChapterPageDownloadState extends ConsumerState ], )); } - return _isStarted - ? SizedBox( - height: 50, - width: 50, - child: PopupMenuButton( - popUpAnimationStyle: popupAnimationStyle, - child: _downloadWidget(context, true), - onSelected: (value) { - if (value == 0) { - _cancelTasks(); - } else if (value == 1) { - _cancelTasks(); - _startDownload(false); - } - }, - itemBuilder: (context) => [ - PopupMenuItem( - value: 1, child: Text(l10n.start_downloading)), - PopupMenuItem(value: 0, child: Text(l10n.cancel)), - ], - )) - : IconButton( - splashRadius: 5, - iconSize: 17, - onPressed: () { - _startDownload(null); - setState(() { - _isStarted = true; - }); - }, - icon: _downloadWidget(context, false), - ); + return IconButton( + splashRadius: 5, + iconSize: 17, + onPressed: () { + _startDownload(null, null, ref); + }, + icon: _downloadWidget(context, false), + ); }, ), ), ); } - void _cancelTasks() async { - setState(() { - _isStarted = false; - }); - _pageUrls = (isar.settings.getSync(227)!.chapterPageUrlsList ?? []) - .where((element) => element.chapterId == widget.chapter.id) - .map((e) => e.urls) - .firstOrNull ?? - []; - await FileDownloader().cancelTasksWithIds(_pageUrls); - await Future.delayed(const Duration(seconds: 2)); - final chapterD = isar.downloads - .filter() - .chapterIdEqualTo(widget.chapter.id!) - .findFirstSync(); - if (chapterD != null) { - final verifyId = isar.downloads.getSync(chapterD.id!); - isar.writeTxnSync(() { - if (verifyId != null) { - isar.downloads.deleteSync(chapterD.id!); - } - }); - } + void _cancelTasks({int? downloadId}) async { + chapter.cancelDownloads(downloadId); } - - @override - bool get wantKeepAlive => true; } Widget _downloadWidget(BuildContext context, bool isLoading) { diff --git a/lib/modules/manga/download/providers/download_provider.dart b/lib/modules/manga/download/providers/download_provider.dart index ff756f3..203d18b 100644 --- a/lib/modules/manga/download/providers/download_provider.dart +++ b/lib/modules/manga/download/providers/download_provider.dart @@ -1,72 +1,62 @@ import 'dart:convert'; import 'dart:io'; -import 'dart:typed_data'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:mangayomi/eval/model/m_bridge.dart'; import 'package:mangayomi/models/manga.dart'; import 'package:mangayomi/models/page.dart'; -import 'package:mangayomi/services/background_downloader/background_downloader.dart'; -import 'package:isar/isar.dart'; import 'package:mangayomi/main.dart'; import 'package:mangayomi/models/chapter.dart'; import 'package:mangayomi/models/download.dart'; import 'package:mangayomi/models/settings.dart'; import 'package:mangayomi/modules/manga/download/providers/convert_to_cbz.dart'; import 'package:mangayomi/modules/more/settings/downloads/providers/downloads_state_provider.dart'; +import 'package:mangayomi/providers/l10n_providers.dart'; import 'package:mangayomi/providers/storage_provider.dart'; +import 'package:mangayomi/router/router.dart'; +import 'package:mangayomi/services/download_manager/m_downloader.dart'; import 'package:mangayomi/services/get_video_list.dart'; import 'package:mangayomi/services/get_chapter_pages.dart'; import 'package:mangayomi/services/http/m_client.dart'; -import 'package:mangayomi/services/m3u8/m3u8_downloader.dart'; +import 'package:mangayomi/services/download_manager/m3u8/m3u8_downloader.dart'; +import 'package:mangayomi/services/download_manager/m3u8/models/download.dart'; import 'package:mangayomi/utils/extensions/string_extensions.dart'; import 'package:mangayomi/utils/headers.dart'; import 'package:mangayomi/utils/reg_exp_matcher.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as p; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; part 'download_provider.g.dart'; @riverpod -Future> downloadChapter( +Future downloadChapter( Ref ref, { required Chapter chapter, bool? useWifi, }) async { + bool onlyOnWifi = useWifi ?? ref.watch(onlyOnWifiStateProvider); + final connectivity = await Connectivity().checkConnectivity(); + final isOnWifi = connectivity.contains(ConnectivityResult.wifi); + if (onlyOnWifi && !isOnWifi) { + botToast(navigatorKey.currentContext!.l10n.downloads_are_limited_to_wifi); + return; + } final http = MClient.init( reqcopyWith: {'useDartHttpClient': true, 'followRedirects': false}); + List pageUrls = []; - List tasks = []; + List pages = []; final StorageProvider storageProvider = StorageProvider(); await storageProvider.requestPermission(); - final tempDir = await getTemporaryDirectory(); - final mangaDir = await storageProvider.getMangaMainDirectory(chapter); - bool onlyOnWifi = useWifi ?? ref.watch(onlyOnWifiStateProvider); - Directory? path; + final mangaMainDirectory = + await storageProvider.getMangaMainDirectory(chapter); + bool isOk = false; final manga = chapter.manga.value!; - final path1 = await storageProvider.getDirectory(); - String scanlator = (chapter.scanlator?.isNotEmpty ?? false) - ? "${chapter.scanlator!.replaceForbiddenCharacters('_')}_" - : ""; final chapterName = chapter.name!.replaceForbiddenCharacters(' '); - final itemType = chapter.manga.value!.itemType; - final itemTypePath = itemType == ItemType.manga - ? "Manga" - : itemType == ItemType.anime - ? "Anime" - : "Novel"; - final pathSegments = [ - "downloads", - itemTypePath, - "${manga.source} (${manga.lang!.toUpperCase()})", - manga.name!.replaceForbiddenCharacters('_'), - ]; - if (itemType == ItemType.manga) { - pathSegments.add(scanlator); - pathSegments.add(chapter.name!.replaceForbiddenCharacters('_')); - } - final finalPath = p.joinAll(pathSegments); - path = Directory(p.join(path1!.path, finalPath)); + final chapterDirectory = + (await storageProvider.getMangaChapterDirectory(chapter))!; + await Directory(chapterDirectory.path).create(recursive: true); Map videoHeader = {}; Map htmlHeader = { "Priority": "u=0, i", @@ -76,24 +66,54 @@ Future> downloadChapter( bool hasM3U8File = false; bool nonM3U8File = false; M3u8Downloader? m3u8Downloader; - Uint8List? tsKey; - Uint8List? tsIv; - int? m3u8MediaSequence; Future processConvert() async { - if (itemType == ItemType.novel) return; - if (hasM3U8File) { - await m3u8Downloader?.mergeTsToMp4(p.join(path!.path, "$chapterName.mp4"), - p.join(path.path, chapterName)); + if (ref.watch(saveAsCBZArchiveStateProvider)) { + await ref.watch(convertToCBZProvider( + chapterDirectory.path, + mangaMainDirectory!.path, + chapter.name!, + pageUrls.map((e) => e.url).toList()) + .future); + } + } + + Future setProgress(DownloadProgress progress) async { + if (progress.isCompleted && itemType == ItemType.manga) { + await processConvert(); + } + final download = isar.downloads.getSync(chapter.id!); + if (download == null) { + final download = Download( + id: chapter.id, + succeeded: progress.completed == 0 + ? 0 + : (progress.completed / progress.total * 100).toInt(), + failed: 0, + total: 100, + isDownload: progress.isCompleted, + isStartDownload: true, + ); + isar.writeTxnSync(() { + isar.downloads.putSync(download..chapter.value = chapter); + }); } else { - if (ref.watch(saveAsCBZArchiveStateProvider)) { - await ref.watch(convertToCBZProvider(path!.path, mangaDir!.path, - chapter.name!, pageUrls.map((e) => e.url).toList()) - .future); + final download = isar.downloads.getSync(chapter.id!); + if (download != null && progress.total != 0) { + isar.writeTxnSync(() { + isar.downloads.putSync(download + ..succeeded = progress.completed == 0 + ? 0 + : (progress.completed / progress.total * 100).toInt() + ..total = 100 + ..failed = 0 + ..isDownload = progress.isCompleted); + }); } } } + setProgress(DownloadProgress(0, 0, itemType)); void savePageUrls() { final settings = isar.settings.getSync(227)!; List? chapterPageUrls = []; @@ -141,18 +161,16 @@ Future> downloadChapter( hasM3U8File = nonM3U8File ? false : m3u8Urls.isNotEmpty; final videosUrls = nonM3U8File ? nonM3u8Urls : m3u8Urls; if (videosUrls.isNotEmpty) { - List tsList = []; if (hasM3U8File) { m3u8Downloader = M3u8Downloader( m3u8Url: videosUrls.first.url, - downloadDir: p.join(path!.path, chapterName), - headers: videosUrls.first.headers ?? {}); - (tsList, tsKey, tsIv, m3u8MediaSequence) = - await m3u8Downloader!.getTsList(); + downloadDir: chapterDirectory.path, + headers: videosUrls.first.headers ?? {}, + fileName: p.join(mangaMainDirectory!.path, "$chapterName.mp4"), + chapter: chapter); + } else { + pageUrls = [PageUrl(videosUrls.first.url)]; } - pageUrls = hasM3U8File - ? [...tsList.map((e) => PageUrl(e.url))] - : [PageUrl(videosUrls.first.url)]; videoHeader.addAll(videosUrls.first.headers ?? {}); isOk = true; } @@ -188,32 +206,23 @@ Future> downloadChapter( if (pageUrls.isNotEmpty) { bool cbzFileExist = - await File(p.join(mangaDir!.path, "${chapter.name}.cbz")).exists() && + await File(p.join(mangaMainDirectory!.path, "${chapter.name}.cbz")) + .exists() && ref.watch(saveAsCBZArchiveStateProvider); bool mp4FileExist = - await File(p.join(mangaDir.path, "$chapterName.mp4")).exists(); + await File(p.join(mangaMainDirectory.path, "$chapterName.mp4")) + .exists(); bool htmlFileExist = - await File(p.join(mangaDir.path, "$chapterName.html")).exists(); + await File(p.join(mangaMainDirectory.path, "$chapterName.html")) + .exists(); if (!cbzFileExist && itemType == ItemType.manga || !mp4FileExist && itemType == ItemType.anime || !htmlFileExist && itemType == ItemType.novel) { for (var index = 0; index < pageUrls.length; index++) { - final path2 = Directory(p.join( - path1.path, - "downloads", - itemType == ItemType.manga - ? "Manga" - : itemType == ItemType.anime - ? "Anime" - : "Novel", - "${manga.source} (${manga.lang!.toUpperCase()})", - manga.name!.replaceForbiddenCharacters('_'))); - if (!(await path2.exists())) { - await path2.create(recursive: true); - } + final mainDirectory = (await storageProvider.getDirectory())!; if (Platform.isAndroid) { - if (!(await File(p.join(path1.path, ".nomedia")).exists())) { - await File(p.join(path1.path, ".nomedia")).create(); + if (!(await File(p.join(mainDirectory.path, ".nomedia")).exists())) { + await File(p.join(mainDirectory.path, ".nomedia")).create(); } } final page = pageUrls[index]; @@ -233,219 +242,65 @@ Future> downloadChapter( pageHeaders.addAll(page.headers ?? {}); if (itemType == ItemType.manga) { - final file = File(p.join(tempDir.path, "Mangayomi", finalPath, - "${padIndex(index + 1)}.jpg")); - if (file.existsSync()) { - Directory(path.path).createSync(recursive: true); - await file.copy(p.join(path.path, "${padIndex(index + 1)}.jpg")); - await file.delete(); - } else { - if (!(await path.exists())) { - await path.create(); - } - if (!(await File(p.join(path.path, "${padIndex(index + 1)}.jpg")) - .exists())) { - tasks.add(DownloadTask( - taskId: page.url, - headers: pageHeaders, - url: page.url.trim().trimLeft().trimRight(), - filename: "${padIndex(index + 1)}.jpg", - baseDirectory: BaseDirectory.temporary, - directory: p.join('Mangayomi', finalPath), - updates: Updates.statusAndProgress, - retries: 3, - allowPause: true, - requiresWiFi: onlyOnWifi)); - } + final file = + File(p.join(chapterDirectory.path, "${padIndex(index + 1)}.jpg")); + if (!file.existsSync()) { + pages.add(PageUrl( + page.url.trim().trimLeft().trimRight(), + headers: pageHeaders, + fileName: + p.join(chapterDirectory.path, "${padIndex(index + 1)}.jpg"), + )); } } else if (itemType == ItemType.anime) { - final file = File( - p.join(tempDir.path, "Mangayomi", finalPath, "$chapterName.mp4")); - if (file.existsSync()) { - await file.copy(p.join(path.path, "$chapterName.mp4")); - await file.delete(); - } else if (hasM3U8File) { - final tempFile = File(p.join(tempDir.path, "Mangayomi", finalPath, - chapterName, "TS_${index + 1}.ts")); - final file = - File(p.join(path.path, chapterName, "TS_${index + 1}.ts")); - if (tempFile.existsSync()) { - Directory(p.join(path.path, chapterName)) - .createSync(recursive: true); - await tempFile - .copy(p.join(path.path, chapterName, "TS_${index + 1}.ts")); - await tempFile.delete(); - } else if (!(file.existsSync())) { - tasks.add(DownloadTask( - taskId: page.url, - headers: pageHeaders, - url: page.url.trim().trimLeft().trimRight(), - filename: "TS_${index + 1}.ts", - baseDirectory: BaseDirectory.temporary, - directory: p.join('Mangayomi', finalPath, chapterName), - updates: Updates.statusAndProgress, - allowPause: true, - retries: 3, - requiresWiFi: onlyOnWifi)); - } - } else { - if (!(await path.exists())) { - await path.create(); - } - if (!(await File(p.join(path.path, "$chapterName.mp4")).exists())) { - tasks.add(DownloadTask( - taskId: page.url, - headers: pageHeaders, - url: page.url.trim().trimLeft().trimRight(), - filename: "$chapterName.mp4", - baseDirectory: BaseDirectory.temporary, - directory: p.join("Mangayomi", finalPath), - updates: Updates.statusAndProgress, - allowPause: true, - retries: 3, - requiresWiFi: onlyOnWifi)); - } + final file = + File(p.join(mangaMainDirectory.path, "$chapterName.mp4")); + if (!file.existsSync()) { + pages.add(PageUrl( + page.url.trim().trimLeft().trimRight(), + headers: pageHeaders, + fileName: p.join(mangaMainDirectory.path, "$chapterName.mp4"), + )); } } else { - final file = File(p.join( - tempDir.path, "Mangayomi", finalPath, "$chapterName.html")); - if (file.existsSync()) { - await file.copy(p.join(path.path, "$chapterName.html")); - await file.delete(); - } else { - if (!(await path.exists())) { - await path.create(); - } - if (!(await File(p.join(path.path, "$chapterName.html")) - .exists())) { - tasks.add(DownloadTask( - taskId: page.url, - headers: pageHeaders, - url: page.url.trim().trimLeft().trimRight(), - filename: "$chapterName.html", - baseDirectory: BaseDirectory.temporary, - directory: p.join("Mangayomi", finalPath), - updates: Updates.statusAndProgress, - allowPause: true, - retries: 3, - requiresWiFi: onlyOnWifi)); - } + final file = File(p.join(chapterDirectory.path, "$chapterName.html")); + if (!file.existsSync()) { + pages.add(PageUrl( + page.url.trim().trimLeft().trimRight(), + headers: pageHeaders, + fileName: p.join(chapterDirectory.path, "$chapterName.html"), + )); } } } } - if (tasks.isEmpty && pageUrls.isNotEmpty) { + if (pages.isEmpty && pageUrls.isNotEmpty) { await processConvert(); savePageUrls(); final download = Download( - succeeded: 0, - failed: 0, - total: 0, - isDownload: true, - taskIds: pageUrls.map((e) => e.url).toList(), - isStartDownload: false, - chapterId: chapter.id, - mangaId: manga.id); + id: chapter.id, + succeeded: 0, + failed: 0, + total: 0, + isDownload: true, + isStartDownload: false, + ); isar.writeTxnSync(() { isar.downloads.putSync(download..chapter.value = chapter); }); } else { - if (hasM3U8File) { - await Directory(p.join(path.path, chapterName)).create(recursive: true); - } savePageUrls(); - await FileDownloader().downloadBatch( - tasks, - batchProgressCallback: (succeeded, failed) async { - if (itemType == ItemType.manga || - itemType == ItemType.novel || - hasM3U8File) { - if (succeeded == tasks.length) { - await processConvert(); - } - bool isEmpty = isar.downloads - .filter() - .chapterIdEqualTo(chapter.id!) - .isEmptySync(); - if (isEmpty) { - final download = Download( - succeeded: succeeded, - failed: failed, - total: tasks.length, - isDownload: (succeeded == tasks.length), - taskIds: pageUrls.map((e) => e.url).toList(), - isStartDownload: true, - chapterId: chapter.id, - mangaId: manga.id); - isar.writeTxnSync(() { - isar.downloads.putSync(download..chapter.value = chapter); - }); - } else { - final download = isar.downloads - .filter() - .chapterIdEqualTo(chapter.id!) - .findFirstSync()!; - isar.writeTxnSync(() { - isar.downloads.putSync(download - ..succeeded = succeeded - ..failed = failed - ..isDownload = (succeeded == tasks.length)); - }); - } - } - }, - taskProgressCallback: (taskProgress) async { - final progress = taskProgress.progress; - if (itemType == ItemType.anime && !hasM3U8File) { - bool isEmpty = isar.downloads - .filter() - .chapterIdEqualTo(chapter.id!) - .isEmptySync(); - if (isEmpty) { - final download = Download( - succeeded: (progress * 100).toInt(), - failed: 0, - total: 100, - isDownload: (progress == 1.0), - taskIds: pageUrls.map((e) => e.url).toList(), - isStartDownload: true, - chapterId: chapter.id, - mangaId: manga.id); - isar.writeTxnSync(() { - isar.downloads.putSync(download..chapter.value = chapter); - }); - } else { - final download = isar.downloads - .filter() - .chapterIdEqualTo(chapter.id!) - .findFirstSync()!; - isar.writeTxnSync(() { - isar.downloads.putSync(download - ..succeeded = (progress * 100).toInt() - ..failed = 0 - ..isDownload = (progress == 1.0)); - }); - } - } - if (progress == 1.0) { - final file = File(p.join(tempDir.path, taskProgress.task.directory, - taskProgress.task.filename)); - if (hasM3U8File) { - final newFile = await file.copy( - p.join(path!.path, chapterName, taskProgress.task.filename)); - await file.delete(); - await m3u8Downloader?.processBytes( - newFile, tsKey, tsIv, m3u8MediaSequence); - } else { - await file.copy(p.join(path!.path, taskProgress.task.filename)); - await file.delete(); - } - } - }, - ); + await MDownloader(chapter: chapter, pageUrls: pages).download((progress) { + setProgress(progress); + }); } + } else if (hasM3U8File) { + await m3u8Downloader?.download( + (progress) { + setProgress(progress); + }, + ); } - return pageUrls; } diff --git a/lib/modules/manga/download/providers/download_provider.g.dart b/lib/modules/manga/download/providers/download_provider.g.dart index 938860a..b3faa0e 100644 --- a/lib/modules/manga/download/providers/download_provider.g.dart +++ b/lib/modules/manga/download/providers/download_provider.g.dart @@ -6,7 +6,7 @@ part of 'download_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$downloadChapterHash() => r'2873b00f9f4d0fd91bc90a28e2700a6c0d187a46'; +String _$downloadChapterHash() => r'ef2f5195c42fa8e17fc3d8cd5ffecc67f82472e9'; /// Copied from Dart SDK class _SystemHash { @@ -34,7 +34,7 @@ class _SystemHash { const downloadChapterProvider = DownloadChapterFamily(); /// See also [downloadChapter]. -class DownloadChapterFamily extends Family>> { +class DownloadChapterFamily extends Family> { /// See also [downloadChapter]. const DownloadChapterFamily(); @@ -75,7 +75,7 @@ class DownloadChapterFamily extends Family>> { } /// See also [downloadChapter]. -class DownloadChapterProvider extends AutoDisposeFutureProvider> { +class DownloadChapterProvider extends AutoDisposeFutureProvider { /// See also [downloadChapter]. DownloadChapterProvider({ required Chapter chapter, @@ -115,7 +115,7 @@ class DownloadChapterProvider extends AutoDisposeFutureProvider> { @override Override overrideWith( - FutureOr> Function(DownloadChapterRef provider) create, + FutureOr Function(DownloadChapterRef provider) create, ) { return ProviderOverride( origin: this, @@ -133,7 +133,7 @@ class DownloadChapterProvider extends AutoDisposeFutureProvider> { } @override - AutoDisposeFutureProviderElement> createElement() { + AutoDisposeFutureProviderElement createElement() { return _DownloadChapterProviderElement(this); } @@ -156,7 +156,7 @@ class DownloadChapterProvider extends AutoDisposeFutureProvider> { @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element -mixin DownloadChapterRef on AutoDisposeFutureProviderRef> { +mixin DownloadChapterRef on AutoDisposeFutureProviderRef { /// The parameter `chapter` of this provider. Chapter get chapter; @@ -165,8 +165,7 @@ mixin DownloadChapterRef on AutoDisposeFutureProviderRef> { } class _DownloadChapterProviderElement - extends AutoDisposeFutureProviderElement> - with DownloadChapterRef { + extends AutoDisposeFutureProviderElement with DownloadChapterRef { _DownloadChapterProviderElement(super.provider); @override diff --git a/lib/modules/manga/reader/providers/reader_controller_provider.dart b/lib/modules/manga/reader/providers/reader_controller_provider.dart index 1350c82..a56f03e 100644 --- a/lib/modules/manga/reader/providers/reader_controller_provider.dart +++ b/lib/modules/manga/reader/providers/reader_controller_provider.dart @@ -464,11 +464,8 @@ extension MangaExtensions on Manga { ? element.isBookmarked == false : true) .where((element) { - final modelChapDownload = isar.downloads - .filter() - .idIsNotNull() - .chapterIdEqualTo(element.id) - .findAllSync(); + final modelChapDownload = + isar.downloads.filter().idEqualTo(element.id).findAllSync(); return filterDownloaded == 1 ? modelChapDownload.isNotEmpty && modelChapDownload.first.isDownload == true diff --git a/lib/modules/more/data_and_storage/providers/restore.dart b/lib/modules/more/data_and_storage/providers/restore.dart index a0e77bb..87e3013 100644 --- a/lib/modules/more/data_and_storage/providers/restore.dart +++ b/lib/modules/more/data_and_storage/providers/restore.dart @@ -109,7 +109,7 @@ void restoreBackup(Ref ref, Map backup) { isar.downloads.clearSync(); if (downloads != null) { for (var download in downloads) { - final chapter = isar.chapters.getSync(download.chapterId!); + final chapter = isar.chapters.getSync(download.id!); if (chapter != null) { isar.downloads.putSync(download..chapter.value = chapter); download.chapter.saveSync(); diff --git a/lib/modules/more/data_and_storage/providers/restore.g.dart b/lib/modules/more/data_and_storage/providers/restore.g.dart index 8cdc59a..e833da1 100644 --- a/lib/modules/more/data_and_storage/providers/restore.g.dart +++ b/lib/modules/more/data_and_storage/providers/restore.g.dart @@ -173,7 +173,7 @@ class _DoRestoreProviderElement extends AutoDisposeProviderElement BuildContext get context => (origin as DoRestoreProvider).context; } -String _$restoreBackupHash() => r'1cc45d864473761c65d4ce52074e4bd9c513e91d'; +String _$restoreBackupHash() => r'24405b9be28204324e47d6c1db34495d55a491d2'; /// See also [restoreBackup]. @ProviderFor(restoreBackup) diff --git a/lib/modules/more/download_queue/download_queue_screen.dart b/lib/modules/more/download_queue/download_queue_screen.dart index d868e7f..54de488 100644 --- a/lib/modules/more/download_queue/download_queue_screen.dart +++ b/lib/modules/more/download_queue/download_queue_screen.dart @@ -1,12 +1,12 @@ -import 'package:mangayomi/services/background_downloader/background_downloader.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:grouped_list/grouped_list.dart'; import 'package:isar/isar.dart'; import 'package:mangayomi/main.dart'; +import 'package:mangayomi/models/chapter.dart'; import 'package:mangayomi/models/download.dart'; -import 'package:mangayomi/models/settings.dart'; import 'package:mangayomi/providers/l10n_providers.dart'; +import 'package:mangayomi/utils/extensions/chapter.dart'; import 'package:mangayomi/utils/global_style.dart'; class DownloadQueueScreen extends ConsumerWidget { @@ -123,70 +123,22 @@ class DownloadQueueScreen extends ConsumerWidget { child: const Icon(Icons.more_vert), onSelected: (value) async { if (value.toString() == 'Cancel') { - final taskIds = (isar.settings - .getSync(227)! - .chapterPageUrlsList ?? - []) - .where((e) => - e.chapterId == element.chapterId!) - .map((e) => e.urls) - .firstOrNull ?? - []; - FileDownloader() - .cancelTasksWithIds(taskIds) - .then((value) async { - await Future.delayed( - const Duration(seconds: 1)); - isar.writeTxnSync(() { - int id = isar.downloads - .filter() - .chapterIdEqualTo( - element.chapter.value!.id) - .findFirstSync()! - .id!; - isar.downloads.deleteSync(id); - }); - }); + element.chapter.value + ?.cancelDownloads(element.id!); } else if (value.toString() == 'CancelAll') { - final chapterIds = entries + final a = entries .where((e) => - e.chapter.value?.manga.value?.name == - element.chapter.value?.manga.value - ?.name && - e.chapter.value?.manga.value?.source == - element.chapter.value?.manga.value - ?.source) - .map((e) => e.chapterId) + '${e.chapter.value?.manga.value?.name}' == + '${element.chapter.value?.manga.value?.name}' && + '${e.chapter.value?.manga.value?.source}' == + '${element.chapter.value?.manga.value?.source}') + .map((e) => (e.id, e.chapter.value?.id)) .toList(); - for (var chapterId in chapterIds) { - final taskIds = (isar.settings - .getSync(227)! - .chapterPageUrlsList ?? - []) - .where((e) => e.chapterId == chapterId!) - .map((e) => e.urls) - .firstOrNull ?? - []; - await FileDownloader() - .cancelTasksWithIds(taskIds); - Future.delayed(const Duration(seconds: 2)).then( - (value) { - final chapterD = isar.downloads - .filter() - .chapterIdEqualTo(chapterId) - .findFirstSync(); - if (chapterD != null) { - final verifyId = - isar.downloads.getSync(chapterD.id!); - isar.writeTxnSync(() { - if (verifyId != null) { - isar.downloads - .deleteSync(chapterD.id!); - } - }); - } - }, - ); + for (var ids in a) { + final (downloadId, chapterId) = ids; + final chapter = + isar.chapters.getSync(chapterId!); + chapter?.cancelDownloads(downloadId!); } } }, diff --git a/lib/modules/novel/novel_reader_controller_provider.dart b/lib/modules/novel/novel_reader_controller_provider.dart index 9317d6a..513b01a 100644 --- a/lib/modules/novel/novel_reader_controller_provider.dart +++ b/lib/modules/novel/novel_reader_controller_provider.dart @@ -249,7 +249,7 @@ extension MangaExtensions on Manga { final modelChapDownload = isar.downloads .filter() .idIsNotNull() - .chapterIdEqualTo(element.id) + .idEqualTo(element.id) .findAllSync(); return filterDownloaded == 1 ? modelChapDownload.isNotEmpty && diff --git a/lib/modules/webview/webview.dart b/lib/modules/webview/webview.dart index 272a5b1..de31895 100644 --- a/lib/modules/webview/webview.dart +++ b/lib/modules/webview/webview.dart @@ -59,7 +59,9 @@ class _MangaWebViewState extends ConsumerState { ..launch(widget.url) ..onClose.whenComplete(() { timer.cancel(); - Navigator.pop(context); + if (mounted) { + Navigator.pop(context); + } }); } else { browser = MyInAppBrowser( diff --git a/lib/providers/storage_provider.dart b/lib/providers/storage_provider.dart index a2977e8..9267089 100644 --- a/lib/providers/storage_provider.dart +++ b/lib/providers/storage_provider.dart @@ -95,7 +95,7 @@ class StorageProvider { : "Novel"; final dir = await getDirectory(); return Directory( - "${dir!.path}/downloads/$itemTypePath/${manga.source} (${manga.lang!.toUpperCase()})/${manga.name!.replaceForbiddenCharacters('_')}/$scanlator${chapter.name!.replaceForbiddenCharacters('_')}/" + "${dir!.path}downloads/$itemTypePath/${manga.source} (${manga.lang!.toUpperCase()})/${manga.name!.replaceForbiddenCharacters('_')}/$scanlator${chapter.name!.replaceForbiddenCharacters('_')}/" .fixSeparator); } diff --git a/lib/services/aniskip.g.dart b/lib/services/aniskip.g.dart index c05db92..b2e1def 100644 --- a/lib/services/aniskip.g.dart +++ b/lib/services/aniskip.g.dart @@ -6,7 +6,7 @@ part of 'aniskip.dart'; // RiverpodGenerator // ************************************************************************** -String _$aniSkipHash() => r'2e5d19b025a2207ff64da7bf7908450ea9e5ff8c'; +String _$aniSkipHash() => r'887869b54e2e151633efd46da83bde845e14f421'; /// See also [AniSkip]. @ProviderFor(AniSkip) diff --git a/lib/services/background_downloader/LICENSE b/lib/services/background_downloader/LICENSE deleted file mode 100644 index b87579b..0000000 --- a/lib/services/background_downloader/LICENSE +++ /dev/null @@ -1,37 +0,0 @@ -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. \ No newline at end of file diff --git a/lib/services/background_downloader/background_downloader.dart b/lib/services/background_downloader/background_downloader.dart deleted file mode 100644 index e40f8a3..0000000 --- a/lib/services/background_downloader/background_downloader.dart +++ /dev/null @@ -1,7 +0,0 @@ -export 'src/file_downloader.dart'; -export 'src/task.dart'; -export 'src/models.dart'; -export 'src/exceptions.dart'; -export 'src/database.dart'; -export 'src/persistent_storage.dart'; -export 'src/queue/task_queue.dart'; diff --git a/lib/services/background_downloader/src/base_downloader.dart b/lib/services/background_downloader/src/base_downloader.dart deleted file mode 100644 index 203668a..0000000 --- a/lib/services/background_downloader/src/base_downloader.dart +++ /dev/null @@ -1,897 +0,0 @@ -// ignore_for_file: depend_on_referenced_packages - -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 'package:mangayomi/services/background_downloader/src/downloader/downloader_http_client.dart'; -import 'database.dart'; -import 'exceptions.dart'; -import 'models.dart'; -import 'persistent_storage.dart'; -import 'queue/task_queue.dart'; -import 'task.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'; - - /// Completes when initialization is complete and downloader ready for use - final _readyCompleter = Completer(); - - /// True when initialization is complete and downloader ready for use - Future get ready => _readyCompleter.future; - - /// Persistent storage - late final PersistentStorage _storage; - late final Database database; - - /// Set of tasks that are in `waitingToRetry` status - final tasksWaitingToRetry = {}; - - /// Completers used to check task completion in convenience functions - final awaitTasks = >{}; - - /// Registered short status callback for convenience down/upload tasks - /// - /// Short callbacks omit the [Task] as they are available from the closure - final _shortTaskStatusCallbacks = {}; - - /// Registered short progress callback for convenience down/upload tasks - /// - /// Short callbacks omit the [Task] as they are available from the closure - final _shortTaskProgressCallbacks = {}; - - /// Registered [TaskStatusCallback] for convenience batch down/upload tasks - final _taskStatusCallbacks = {}; - - /// Registered [TaskProgressCallback] for convenience batch down/upload tasks - final _taskProgressCallbacks = {}; - - /// Registered [TaskStatusCallback] for each group - final groupStatusCallbacks = {}; - - /// Registered [TaskProgressCallback] for each group - final groupProgressCallbacks = {}; - - /// Active batches - final _batches = []; - - /// Registered [TaskNotificationTapCallback] for each group - final groupNotificationTapCallbacks = {}; - - /// List of notification configurations - final notificationConfigs = []; - - /// StreamController for [TaskUpdate] updates - var updates = StreamController(); - - /// Groups tracked in persistent database - final trackedGroups = {}; - - /// Map of tasks and completer to indicate whether task can be resumed - final canResumeTask = >{}; - - /// Flag indicating we have retrieved missed data - @visibleForTesting - var retrievedLocallyStoredData = false; - - /// Connected TaskQueues that will receive a signal upon task completion - final taskQueues = []; - - BaseDownloader(); - - factory BaseDownloader.instance( - PersistentStorage persistentStorage, Database database) { - final instance = DownloaderHttpClient(); - 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 initialize() async { - await _storage.initialize(); - _readyCompleter.complete(true); - } - - /// 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> 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 'not implemented' 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 retrieveLocallyStoredData() async { - if (!retrievedLocallyStoredData) { - final resumeDataMap = await popUndeliveredData(Undelivered.resumeData); - for (var jsonString in resumeDataMap.values) { - final resumeData = ResumeData.fromJsonString(jsonString); - await setResumeData(resumeData); - await setPausedTask(resumeData.task); - } - final statusUpdateMap = - await popUndeliveredData(Undelivered.statusUpdates); - for (var jsonString in statusUpdateMap.values) { - processStatusUpdate(TaskStatusUpdate.fromJsonString(jsonString)); - } - final progressUpdateMap = - await popUndeliveredData(Undelivered.progressUpdates); - for (var jsonString in progressUpdateMap.values) { - processProgressUpdate(TaskProgressUpdate.fromJsonString(jsonString)); - } - 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 || task is DataTask) { - 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 enqueue(Task task) async { - if (task.allowPause) { - canResumeTask[task] = Completer(); - } - return true; - } - - /// Enqueue the [task] and wait for completion - /// - /// Returns the final [TaskStatus] of the [task]. - /// This method is used to enqueue: - /// 1. `download` and `upload` tasks, which may have a short callback - /// for status and progress (omitting Task) - /// 2. `downloadBatch` and `uploadBatch`, which may have a full callback - /// that is used for every task in the batch - Future enqueueAndAwait(Task task, - {void Function(TaskStatus)? onStatus, - void Function(double)? onProgress, - TaskStatusCallback? taskStatusCallback, - TaskProgressCallback? taskProgressCallback, - void Function(Duration)? onElapsedTime, - Duration? elapsedTimeInterval}) async { - // store the task-specific callbacks - if (onStatus != null) { - _shortTaskStatusCallbacks[task.taskId] = onStatus; - } - if (onProgress != null) { - _shortTaskProgressCallbacks[task.taskId] = onProgress; - } - if (taskStatusCallback != null) { - _taskStatusCallbacks[task.taskId] = taskStatusCallback; - } - if (taskProgressCallback != null) { - _taskProgressCallbacks[task.taskId] = taskProgressCallback; - } - // make sure the `updates` field is set correctly - final requiredUpdates = onProgress != null || taskProgressCallback != null - ? Updates.statusAndProgress - : Updates.status; - final Task taskToEnqueue; - if (task.updates != requiredUpdates) { - log.warning( - 'TaskId ${task.taskId} has `updates` set to ${task.updates} but this should be ' - '$requiredUpdates. Change to avoid issues.'); - taskToEnqueue = task.copyWith(updates: requiredUpdates); - } else { - taskToEnqueue = task; - } - // start the elapsedTime timer if necessary. It is cancelled when the - // taskCompleter completes (when the task itself completes) - Timer? timer; - if (onElapsedTime != null) { - final interval = elapsedTimeInterval ?? const Duration(seconds: 5); - timer = Timer.periodic(interval, (timer) { - onElapsedTime(interval * timer.tick); - }); - } - // Create taskCompleter and enqueue the task. - // The completer will be completed in the internal status callback - final taskCompleter = Completer(); - awaitTasks[taskToEnqueue] = taskCompleter; - final enqueueSuccess = await enqueue(taskToEnqueue); - if (!enqueueSuccess) { - log.warning('Could not enqueue task $taskToEnqueue'); - return Future.value(TaskStatusUpdate(taskToEnqueue, TaskStatus.failed, - TaskException('Could not enqueue task $taskToEnqueue'))); - } - if (timer != null) { - taskCompleter.future.then((_) => timer?.cancel()); - } - return taskCompleter.future; - } - - /// Enqueue a list of tasks and wait for completion - /// - /// Returns a [Batch] object - Future enqueueAndAwaitBatch(final List tasks, - {BatchProgressCallback? batchProgressCallback, - TaskStatusCallback? taskStatusCallback, - TaskProgressCallback? taskProgressCallback, - void Function(Duration)? onElapsedTime, - Duration? elapsedTimeInterval}) async { - assert(tasks.isNotEmpty, 'List of tasks cannot be empty'); - if (batchProgressCallback != null) { - batchProgressCallback(0, 0); // initial callback - } - Timer? timer; - if (onElapsedTime != null) { - final interval = elapsedTimeInterval ?? const Duration(seconds: 5); - timer = Timer.periodic(interval, (timer) { - onElapsedTime(interval * timer.tick); - }); - } - final batch = Batch(tasks, batchProgressCallback); - _batches.add(batch); - final taskFutures = >[]; - var counter = 0; - for (final task in tasks) { - taskFutures.add(enqueueAndAwait(task, - taskStatusCallback: taskStatusCallback, - taskProgressCallback: taskProgressCallback)); - if (counter++ % 3 == 0) { - // To prevent blocking the UI we 'yield' for a few ms after every 3 - // tasks we enqueue - await Future.delayed(const Duration(milliseconds: 50)); - } - } - await Future.wait(taskFutures); // wait for all tasks to complete - _batches.remove(batch); - timer?.cancel(); - return batch; - } - - /// Resets the download worker by cancelling all ongoing tasks for the group - /// - /// Returns the number of tasks canceled - @mustCallSuper - Future 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 (final task in pausedTasks) { - if (task.group == group) { - await removePausedTask(task.taskId); - pausedCount++; - } - } - final awaitTasksToRemove = - awaitTasks.keys.where((task) => task.group == group).toList(); - for (final task in awaitTasksToRemove) { - awaitTasks.remove(task); - } - return retryCount + pausedCount + awaitTasksToRemove.length; - } - - /// Returns a list of all tasks in progress, matching [group] - @mustCallSuper - Future> allTasks( - String group, bool includeTasksWaitingToRetry) async { - final tasks = []; - 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 cancelTasksWithIds(List 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)); - updateNotification(task, null); // remove notification - } - 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 cancelPlatformTasksWithIds(List taskIds); - - /// Cancel paused tasks - /// - /// Deletes the associated temp file and emits [TaskStatus.cancel] - Future cancelPausedPlatformTasksWithIds( - List pausedTasks, List 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)); - updateNotification(task, null); // remove notification - } - } - } - - /// Returns Task for this taskId, or nil - @mustCallSuper - Future 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 trackTasks(String? group, bool markDownloadedComplete) async { - await ready; // no database operations until ready - 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 pause(Task task); - - /// Attempt to resume this [task] - /// - /// Returns true if successful - @mustCallSuper - Future 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; - } - - /// Set WiFi requirement globally, based on [requirement]. - /// - /// Affects future tasks and reschedules enqueued, inactive tasks - /// with the new setting. - /// Reschedules running tasks if [rescheduleRunningTasks] is true, - /// otherwise leaves those running with their prior setting - Future requireWiFi(RequireWiFi requirement, rescheduleRunningTasks) => - Future.value(true); - - /// Returns the current global setting for requiring WiFi - Future getRequireWiFiSetting() => - Future.value(RequireWiFi.asSetByTask); - - /// 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 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 setResumeData(ResumeData resumeData) => - _storage.storeResumeData(resumeData); - - /// Retrieve the resume data for this [taskId] - Future getResumeData(String taskId) => - _storage.retrieveResumeData(taskId); - - /// Remove resumeData for this [taskId], or all if null - Future removeResumeData([String? taskId]) => - _storage.removeResumeData(taskId); - - /// Store the paused [task] - Future setPausedTask(Task task) => _storage.storePausedTask(task); - - /// Return a stored paused task with this [taskId], or null if not found - Future getPausedTask(String taskId) => - _storage.retrievePausedTask(taskId); - - /// Return a list of paused [Task] objects - Future> getPausedTasks() => _storage.retrieveAllPausedTasks(); - - /// Remove paused task for this taskId, or all if null - Future removePausedTask([String? taskId]) => - _storage.removePausedTask(taskId); - - /// Retrieve data that was not delivered to Dart - Future> 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 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 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 openFile(Task? task, String? filePath, String? mimeType); - - /// Return the platform version as a String - Future platformVersion() => - Future.value(Platform.operatingSystemVersion); - - // Testing methods - - /// Get the duration for a task to timeout - Android only, for testing - @visibleForTesting - Future getTaskTimeout(); - - /// Set forceFailPostOnBackgroundChannel for native downloader - @visibleForTesting - Future setForceFailPostOnBackgroundChannel(bool value); - - /// Test suggested filename based on task and content disposition header - @visibleForTesting - Future testSuggestedFilename( - DownloadTask task, String contentDisposition); - - // Helper methods - - /// Closes the [updates] stream and re-initializes the [StreamController] - /// such that the stream can be listened to again - Future 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'); - _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) { - _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 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) { - // handle the statusUpdate in order of priority: - // handle [awaitTasks], otherwise try [groupStatusCallbacks], - // otherwise try [updates] listener, otherwise log warning - // for missing handler - if (awaitTasks.containsKey(task)) { - _awaitTaskStatusCallback(update); - } else { - 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) { - // handle the progressUpdate in order of priority: - // handle [awaitTasks], otherwise try [groupProgressCallbacks], - // otherwise try [updates] listener, otherwise log warning - // for missing handler - _updateTaskInDatabase(task, - progress: update.progress, expectedFileSize: update.expectedFileSize); - if (awaitTasks.containsKey(task)) { - _awaitTaskProgressCallBack(update); - } else { - 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'); - } - } - } - } - - /// Update or remove notification for task - /// - /// If [taskStatusOrNull] is null, removes notification - void updateNotification(Task task, TaskStatus? taskStatusOrNull) {} - - /// Internal callback function for [awaitTasks] that passes the update - /// on to different callbacks - /// - /// The update is passed on to: - /// 1. Task-specific callback, passed as parameter to [enqueueAndAwait] call - /// 2. Short task-specific callback, passed as parameter to call - /// 3. Batch-related callback, if this task is part of a batch operation - /// and is in a final state - /// - /// If the task is in final state, also removes the reference to the - /// task-specific callbacks and completes the completer associated - /// with this task - _awaitTaskStatusCallback(TaskStatusUpdate statusUpdate) { - final task = statusUpdate.task; - final status = statusUpdate.status; - _shortTaskStatusCallbacks[task.taskId]?.call(status); - _taskStatusCallbacks[task.taskId]?.call(statusUpdate); - if (status.isFinalState) { - if (_batches.isNotEmpty) { - // check if this task is part of a batch - for (final batch in _batches) { - if (batch.tasks.contains(task)) { - batch.results[task] = status; - if (batch.batchProgressCallback != null) { - batch.batchProgressCallback!(batch.numSucceeded, batch.numFailed); - } - break; - } - } - } - _shortTaskStatusCallbacks.remove(task.taskId); - _shortTaskProgressCallbacks.remove(task.taskId); - _taskStatusCallbacks.remove(task.taskId); - _taskProgressCallbacks.remove(task.taskId); - var taskCompleter = awaitTasks.remove(task); - taskCompleter?.complete(statusUpdate); - } - } - - /// Internal callback function that only passes progress updates on - /// to the task-specific progress callback passed as parameter - /// to the [enqueueAndAwait] call - _awaitTaskProgressCallBack(TaskProgressUpdate progressUpdate) { - _shortTaskProgressCallbacks[progressUpdate.task.taskId] - ?.call(progressUpdate.progress); - _taskProgressCallbacks[progressUpdate.task.taskId]?.call(progressUpdate); - } - - /// Insert or update the [TaskRecord] in the tracking database - Future _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(); - _batches.clear(); - awaitTasks.clear(); - _shortTaskStatusCallbacks.clear(); - _shortTaskProgressCallbacks.clear(); - _taskStatusCallbacks.clear(); - _taskProgressCallbacks.clear(); - groupStatusCallbacks.clear(); - groupProgressCallbacks.clear(); - notificationConfigs.clear(); - trackedGroups.clear(); - canResumeTask.clear(); - removeResumeData(); // removes all - removePausedTask(); // removes all - resetUpdatesStreamController(); - } -} diff --git a/lib/services/background_downloader/src/chunk.dart b/lib/services/background_downloader/src/chunk.dart deleted file mode 100644 index 6f1abed..0000000 --- a/lib/services/background_downloader/src/chunk.dart +++ /dev/null @@ -1,115 +0,0 @@ -import 'dart:convert'; - -import 'base_downloader.dart'; -import 'file_downloader.dart'; -import 'models.dart'; -import 'task.dart'; - -/// Class representing a chunk of a download and its status -class Chunk { - // key parameters - final String parentTaskId; - final String url; - final String filename; - final int fromByte; // start byte - final int toByte; // end byte - final DownloadTask task; // task to download this chunk - - // state parameters - late TaskStatus status; - late double progress; - - /// Define a chunk by its key parameters, in default state - /// - /// This also generates the [task] to download this chunk, and that - /// task contains the [parentTaskId] and [toByte] and [fromByte] values of the - /// chunk in its [Task.metaData] field as a JSON encoded map - Chunk( - {required Task parentTask, - required this.url, - required this.filename, - required this.fromByte, - required this.toByte}) - : parentTaskId = parentTask.taskId, - task = DownloadTask( - url: url, - filename: filename, - headers: { - ...parentTask.headers, - 'Range': 'bytes=$fromByte-$toByte' - }, - baseDirectory: BaseDirectory.temporary, - group: BaseDownloader.chunkGroup, - updates: updatesBasedOnParent(parentTask), - retries: parentTask.retries, - allowPause: parentTask.allowPause, - priority: parentTask.priority, - requiresWiFi: parentTask.requiresWiFi, - metaData: jsonEncode({ - 'parentTaskId': parentTask.taskId, - 'from': fromByte, - 'to': toByte - })) { - status = TaskStatus.enqueued; - progress = 0; - } - - /// Creates object from [json] - Chunk.fromJson(Map json) - : parentTaskId = json['parentTaskId'], - url = json['url'], - filename = json['filename'], - fromByte = (json['fromByte'] as num).toInt(), - toByte = (json['toByte'] as num).toInt(), - task = Task.createFromJson(json['task']) as DownloadTask, - status = TaskStatus.values[(json['status'] as num? ?? 0).toInt()], - progress = (json['progress'] as num? ?? 0.0).toDouble(); - - /// Revive List from a JSON map in a jsonDecode operation, - /// where each element is a map representing the [Chunk] - static Object? listReviver(Object? key, Object? value) => - key is int ? Chunk.fromJson(value as Map) : value; - - /// Creates JSON map of this object - Map toJson() => { - 'parentTaskId': parentTaskId, - 'url': url, - 'filename': filename, - 'fromByte': fromByte, - 'toByte': toByte, - 'task': task.toJson(), - 'status': status.index, - 'progress': progress - }; - - /// Return the parentTaskId embedded in the metaData of a chunkTask - static String getParentTaskId(Task task) => - jsonDecode(task.metaData)['parentTaskId'] as String; - - /// Return [Updates] that is based on the [parentTask] - static Updates updatesBasedOnParent(Task parentTask) => - switch (parentTask.updates) { - 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 resumeChunkTasks( - ParallelDownloadTask task, ResumeData resumeData) async { - final chunks = - List.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; -} diff --git a/lib/services/background_downloader/src/database.dart b/lib/services/background_downloader/src/database.dart deleted file mode 100644 index 898710f..0000000 --- a/lib/services/background_downloader/src/database.dart +++ /dev/null @@ -1,195 +0,0 @@ -import 'package:flutter/foundation.dart'; - -import 'base_downloader.dart'; -import 'exceptions.dart'; -import 'models.dart'; -import 'persistent_storage.dart'; -import 'task.dart'; - -/// Persistent database used for tracking task status and progress. -/// -/// Stores [TaskRecord] objects. -/// -/// This object is accessed by the [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> 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> 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> 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 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> recordsForIds(Iterable taskIds) async { - final result = []; - 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 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 deleteRecordWithId(String taskId) => - deleteRecordsWithIds([taskId]); - - /// Delete records with these [taskIds] - Future deleteRecordsWithIds(Iterable 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 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 [json] - TaskRecord.fromJson(Map json) - : task = Task.createFromJson(json), - status = TaskStatus.values[ - (json['status'] as num?)?.toInt() ?? TaskStatus.failed.index], - progress = (json['progress'] as num?)?.toDouble() ?? progressFailed, - expectedFileSize = (json['expectedFileSize'] as num?)?.toInt() ?? -1, - exception = json['exception'] == null - ? null - : TaskException.fromJson(json['exception']); - - /// Returns JSON map representation of this [TaskRecord] - /// - /// Note the [status], [progress] and [exception] fields are merged into - /// the JSON map representation of the [task] - Map toJson() { - final json = task.toJson(); - json['status'] = status.index; - json['progress'] = progress; - json['expectedFileSize'] = expectedFileSize; - json['exception'] = exception?.toJson(); - return json; - } - - /// Copy with optional replacements. [exception] is always copied - TaskRecord copyWith( - {Task? task, - TaskStatus? status, - double? progress, - int? expectedFileSize}) => - TaskRecord( - task ?? this.task, - status ?? this.status, - progress ?? this.progress, - expectedFileSize ?? this.expectedFileSize, - exception); - - @override - String toString() { - 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; -} diff --git a/lib/services/background_downloader/src/downloader/data_isolate.dart b/lib/services/background_downloader/src/downloader/data_isolate.dart deleted file mode 100644 index 22b802a..0000000 --- a/lib/services/background_downloader/src/downloader/data_isolate.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'dart:async'; -import 'dart:isolate'; - -import 'package:http/http.dart' as http; -import 'package:mangayomi/services/background_downloader/src/downloader/downloader_http_client.dart'; -import 'package:mangayomi/services/background_downloader/background_downloader.dart'; -import 'download_isolate.dart'; -import 'isolate.dart'; - -/// Do the data task -/// -/// Sends updates via the [sendPort] and can be commanded to cancel via -/// the [messagesToIsolate] queue -Future doDataTask(DataTask task, SendPort sendPort) async { - final client = DownloaderHttpClient.httpClient; - var request = http.Request(task.httpRequestMethod, Uri.parse(task.url)); - request.headers.addAll(task.headers); - if (task.post is String) { - request.body = task.post!; - } - var resultStatus = TaskStatus.failed; - try { - final response = await client.send(request); - if (!isCanceled) { - responseHeaders = response.headers; - responseStatusCode = response.statusCode; - extractContentType(response.headers); - responseBody = await responseContent(response); - if (okResponses.contains(response.statusCode)) { - resultStatus = TaskStatus.complete; - } else { - if (response.statusCode == 404) { - resultStatus = TaskStatus.notFound; - } else { - taskException = TaskHttpException( - responseBody?.isNotEmpty == true - ? responseBody! - : response.reasonPhrase ?? 'Invalid HTTP Request', - response.statusCode); - } - } - } - } catch (e) { - logError(task, e.toString()); - setTaskError(e); - } - if (isCanceled) { - // cancellation overrides other results - resultStatus = TaskStatus.canceled; - } - processStatusUpdateInIsolate(task, resultStatus, sendPort); -} diff --git a/lib/services/background_downloader/src/downloader/download_isolate.dart b/lib/services/background_downloader/src/downloader/download_isolate.dart deleted file mode 100644 index 7cc009d..0000000 --- a/lib/services/background_downloader/src/downloader/download_isolate.dart +++ /dev/null @@ -1,300 +0,0 @@ -// ignore_for_file: depend_on_referenced_packages - -import 'dart:async'; -import 'dart:io'; -import 'dart:isolate'; -import 'dart:math'; - -import 'package:http/http.dart' as http; -import 'package:mangayomi/services/background_downloader/src/downloader/downloader_http_client.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; - -import '../exceptions.dart'; -import '../models.dart'; -import '../task.dart'; -import '../utils.dart'; -import '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 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 = DownloaderHttpClient.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}'); - } - responseHeaders = response.headers; - responseStatusCode = response.statusCode; - extractContentType(response.headers); - if (okResponses.contains(response.statusCode)) { - resultStatus = await processOkDownloadResponse( - filePath, - tempFilePath, - serverAcceptsRanges, - taskCanResume, - isResume, - requestTimeout, - response, - sendPort); - } else { - // not an OK response - responseBody = await responseContent(response); - if (response.statusCode == 404) { - resultStatus = TaskStatus.notFound; - } else { - taskException = TaskHttpException( - responseBody?.isNotEmpty == true - ? responseBody! - : response.reasonPhrase ?? 'Invalid HTTP Request', - response.statusCode); - } - } - } - } catch (e) { - logError(downloadTask, e.toString()); - setTaskError(e); - } - if (isCanceled) { - // cancellation overrides other results - resultStatus = TaskStatus.canceled; - } - processStatusUpdateInIsolate(downloadTask, resultStatus, sendPort); -} - -/// Return true if resume is possible -/// -/// Confirms that file at [tempFilePath] exists and its length equals -/// [requiredStartByte] -Future 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 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 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'); - } -} - -/// Extract content type from [headers] and set [mimeType] and [charSet] -void extractContentType(Map headers) { - final contentType = headers['content-type']; - if (contentType != null) { - final regEx = RegExp(r'(.*);\s*charset\s*=(.*)'); - final match = regEx.firstMatch(contentType); - if (match != null) { - mimeType = match.group(1); - charSet = match.group(2); - } else { - mimeType = contentType; - } - } -} diff --git a/lib/services/background_downloader/src/downloader/downloader_http_client.dart b/lib/services/background_downloader/src/downloader/downloader_http_client.dart deleted file mode 100644 index bf64470..0000000 --- a/lib/services/background_downloader/src/downloader/downloader_http_client.dart +++ /dev/null @@ -1,596 +0,0 @@ -// ignore_for_file: depend_on_referenced_packages - -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/services.dart'; -import 'package:logging/logging.dart'; -import 'package:mangayomi/services/http/m_client.dart'; -import 'package:mangayomi/src/rust/frb_generated.dart'; -import 'package:path/path.dart' as path; -import 'package:path_provider/path_provider.dart'; -import 'package:mangayomi/services/http/rhttp/rhttp.dart'; -import '../base_downloader.dart'; -import '../chunk.dart'; -import '../exceptions.dart'; -import '../file_downloader.dart'; -import '../models.dart'; -import '../task.dart'; -import '../utils.dart'; -import 'isolate.dart'; - -const okResponses = [200, 201, 202, 203, 204, 205, 206]; - -final class DownloaderHttpClient extends BaseDownloader { - static final _log = Logger('DownloaderHttpClient'); - static const unlimited = 1 << 20; - var maxConcurrent = 10; - var maxConcurrentByHost = unlimited; - var maxConcurrentByGroup = unlimited; - static final DownloaderHttpClient _singleton = - DownloaderHttpClient._internal(); - final _queue = PriorityQueue(); - final _running = Queue(); // subset that is running - final _resume = {}; - final _isolateSendPorts = - {}; // isolate SendPort for running task - static var httpClient = MClient.httpClient( - settings: const ClientSettings( - throwOnStatusCode: false, - tlsSettings: TlsSettings(verifyCertificates: false))); - static Duration? _requestTimeout; - static var _proxy = {}; // 'address' and 'port' - static var _bypassTLSCertificateValidation = false; - - factory DownloaderHttpClient() => _singleton; - - DownloaderHttpClient._internal(); - - @override - Future 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 = _getNextTask(); - if (task != null) { - _running.add(task); - _executeTask(task).then((_) { - _remove(task); - _advanceQueue(); - }); - } else { - return; // if no suitable task, done - } - } - } - - /// Returns a [Task] to run, or null if no suitable task is available - Task? _getNextTask() { - final tasksThatHaveToWait = []; - while (_queue.isNotEmpty) { - final task = _queue.removeFirst(); - if (_numActiveWithHostname(task.hostName) < maxConcurrentByHost && - _numActiveWithGroup(task.group) < maxConcurrentByGroup) { - _queue.addAll(tasksThatHaveToWait); // put back in queue - return task; - } - tasksThatHaveToWait.add(task); - } - _queue.addAll(tasksThatHaveToWait); // put back in queue - return null; - } - - /// Returns number of tasks active with this [hostname] - int _numActiveWithHostname(String hostname) => _running.fold( - 0, - (previousValue, task) => - task.hostName == hostname ? previousValue + 1 : previousValue); - - /// Returns number of tasks active with this [group] - int _numActiveWithGroup(String group) => _running.fold( - 0, - (previousValue, task) => - task.group == group ? previousValue + 1 : previousValue); - - /// Execute this task - /// - /// The task runs on an Isolate, which is sent the task information and - /// which will emit status and progress updates. These updates will be - /// 'forwarded' to the [backgroundChannel] and processed by the - /// [FileDownloader] - Future _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(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, - Map? responseHeaders, - int? responseCode, - String? mimeType, - String? charSet - ): - final taskStatusUpdate = TaskStatusUpdate( - updatedTask, - status, - exception, - responseBody, - responseHeaders, - responseCode, - mimeType, - charSet); - if (updatedTask.group != BaseDownloader.chunkGroup) { - if (status.isFinalState) { - _remove(updatedTask); - } - processStatusUpdate(taskStatusUpdate); - } else { - _parallelTaskSendPort(Chunk.getParentTaskId(updatedTask)) - ?.send(taskStatusUpdate); - } - - case ( - 'progressUpdate', - 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 taskIds): - await FileDownloader().cancelTasksWithIds(taskIds); - - // from [ParallelDownloadTask] - case ('pauseTasks', List 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 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> 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 cancelPlatformTasksWithIds(List 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 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 pause(Task task) async { - final sendPort = _isolateSendPorts[task]; - if (sendPort != null) { - sendPort.send('pause'); - return true; - } - return false; - } - - @override - Future resume(Task task) async { - if (await super.resume(task)) { - task = awaitTasks.containsKey(task) - ? awaitTasks.keys - .firstWhere((awaitTask) => awaitTask.taskId == task.taskId) - : 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> popUndeliveredData(Undelivered dataType) => - Future.value({}); - - @override - Future 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 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 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 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 getTaskTimeout() => Future.value(const Duration(days: 1)); - - @override - Future setForceFailPostOnBackgroundChannel(bool value) { - throw UnimplementedError(); - } - - @override - Future testSuggestedFilename( - DownloadTask task, String contentDisposition) async { - final h = contentDisposition.isNotEmpty - ? {'Content-disposition': contentDisposition} - : {}; - 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; - - case ( - Config.holdingQueue, - ( - int? maxConcurrentParam, - int? maxConcurrentByHostParam, - int? maxConcurrentByGroupParam - ) - ): - maxConcurrent = maxConcurrentParam ?? 10; - maxConcurrentByHost = maxConcurrentByHostParam ?? unlimited; - maxConcurrentByGroup = maxConcurrentByGroupParam ?? unlimited; - - 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 value) { - _proxy = value; - _recreateClient(); - } - - static Map 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 proxy, bool bypassTLSCertificateValidation) { - _requestTimeout = requestTimeout; - _proxy = proxy; - _bypassTLSCertificateValidation = bypassTLSCertificateValidation; - _recreateClient(); - } - - /// Recreates the [httpClient] used for Requests and isolate downloads/uploads - static _recreateClient() async { - await RustLib.init(); - httpClient = MClient.httpClient( - settings: const ClientSettings( - throwOnStatusCode: false, - tlsSettings: TlsSettings(verifyCertificates: false))); - } - - @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); - } -} diff --git a/lib/services/background_downloader/src/downloader/isolate.dart b/lib/services/background_downloader/src/downloader/isolate.dart deleted file mode 100644 index 191442a..0000000 --- a/lib/services/background_downloader/src/downloader/isolate.dart +++ /dev/null @@ -1,390 +0,0 @@ -// ignore_for_file: depend_on_referenced_packages - -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:isolate'; -import 'dart:math'; - -import 'package:async/async.dart'; -import 'package:mangayomi/services/background_downloader/src/downloader/downloader_http_client.dart'; -import 'package:mangayomi/services/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 '../task.dart'; -import 'data_isolate.dart'; -import 'download_isolate.dart'; -import 'parallel_download_isolate.dart'; -import 'upload_isolate.dart'; - -/// global variables, unique to this isolate -var bytesTotal = 0; // total bytes read in this download session -var startByte = - 0; // starting position within the original range, used for resume -var lastProgressUpdateTime = DateTime.fromMillisecondsSinceEpoch(0); -var nextProgressUpdateTime = DateTime.fromMillisecondsSinceEpoch(0); -var lastProgressUpdate = 0.0; -var bytesTotalAtLastProgressUpdate = 0; - -var networkSpeed = 0.0; // in MB/s -var isPaused = false; -var isCanceled = false; - -// additional parameters for final TaskStatusUpdate -TaskException? taskException; -String? responseBody; -Map? responseHeaders; -int? responseStatusCode; -String? mimeType; // derived from Content-Type header -String? charSet; // derived from Content-Type header - -// logging from isolate is always 'FINEST', as it is sent to -// the [DesktopDownloader] for processing -final log = Logger('FileDownloader'); - -/// Do the task, sending messages back to the main isolate via [sendPort] -/// -/// The first message sent back is a [ReceivePort] that is the command port -/// for the isolate. The first command must be the arguments: task and filePath. -Future 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(receivePort); - // get the arguments list and parse each argument - final ( - Task task, - String filePath, - ResumeData? resumeData, - bool isResume, - Duration? requestTimeout, - Map proxy, - bool bypassTLSCertificateValidation - ) = await messagesToIsolate.next; - DownloaderHttpClient.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), - DataTask() => doDataTask(task, 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 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 transferBytes( - Stream> inStream, - StreamSink> 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, - status.isFinalState ? responseHeaders : null, - status == TaskStatus.complete || status == TaskStatus.notFound - ? responseStatusCode - : null, - status.isFinalState ? mimeType : null, - status.isFinalState ? charSet : 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) { - case HttpException(): - case TimeoutException(): - taskException = TaskConnectionException(e.toString()); - - case IOException(): - taskException = TaskFileSystemException(e.toString()); - - case TaskException(): - taskException = e; - - default: - taskException = TaskException(e.toString()); - } -} - -/// Return the response's content as a String, or null if unable -Future 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); -} diff --git a/lib/services/background_downloader/src/downloader/parallel_download_isolate.dart b/lib/services/background_downloader/src/downloader/parallel_download_isolate.dart deleted file mode 100644 index 6fd0d57..0000000 --- a/lib/services/background_downloader/src/downloader/parallel_download_isolate.dart +++ /dev/null @@ -1,406 +0,0 @@ -// ignore_for_file: depend_on_referenced_packages - -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:isolate'; -import 'dart:math'; - -import 'package:collection/collection.dart'; -import 'package:mangayomi/services/background_downloader/src/downloader/downloader_http_client.dart'; -import '../chunk.dart'; -import '../exceptions.dart'; -import '../models.dart'; -import '../task.dart'; -import '../utils.dart'; -import 'download_isolate.dart'; -import 'isolate.dart'; - -/// A [ParallelDownloadTask] pings the server to get the content-length of the -/// download, then creates a list of [Chunk]s, each representing a portion -/// of the download. Each chunk-task has its group set to 'chunk' and -/// has the taskId of the parent [ParallelDownloadTask] in its -/// [Task.metaData] field. -/// The isolate sends 'enqueue' messages back to the [DesktopDownloader] to -/// start each chunk-task, just like any other download task. -/// Messages with group 'chunk' are intercepted in the [DesktopDownloader], -/// where the sendPort for the isolate running the parent task is -/// looked up, and the update is sent to the isolate via that sendPort. -/// In the isolate, the update is processed and the new status/progress -/// of the [ParallelDownloadTask] is determined. If the status/progress has -/// changed, an update is sent and the status is processed (e.g., a complete -/// status triggers the piecing together of the downloaded file from -/// its chunk pieces). -/// -/// Similarly, pause and cancel commands are sent to all chunk tasks before -/// updating the status of the parent [ParallelDownloadTask] - -late ParallelDownloadTask parentTask; -var chunks = []; // chunks associated with this download -var lastTaskStatus = TaskStatus.running; -Completer 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 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 (DownloaderHttpClient.httpClient) - .head(Uri.parse(task.url), headers: task.headers); - responseHeaders = response.headers; - responseStatusCode = response.statusCode; - if ([200, 201, 202, 203, 204, 205, 206].contains(response.statusCode)) { - // get suggested filename if needed, and change task and parentTask - if (!task.hasFilename) { - task = (await taskWithSuggestedFilename(task, response.headers, true)) - as ParallelDownloadTask; - parentTask = task; - log.finest( - 'Suggested filename for taskId ${task.taskId}: ${task.filename}'); - } - extractContentType(response.headers); - chunks = createChunks(task, response.headers); - for (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 chunkStatusUpdate( - Task task, TaskStatusUpdate update, SendPort sendPort) async { - final chunkTask = update.task; - // first check for fail -> retry - if (update.status == TaskStatus.failed && chunkTask.retriesRemaining > 0) { - chunkTask.decreaseRetriesRemaining(); - final waitTime = Duration( - seconds: - 2 << min(chunkTask.retries - chunkTask.retriesRemaining - 1, 8)); - log.finer( - 'Chunk task with taskId ${chunkTask.taskId} failed, waiting ${waitTime.inSeconds}' - ' seconds before retrying. ${chunkTask.retriesRemaining}' - ' retries remaining'); - Future.delayed(waitTime, () async { - // after delay, resume or enqueue task again if it's still waiting - sendPort.send(('enqueueChild', chunkTask)); - }); - } else { - // no retry - final newStatusUpdate = updateChunkStatus(update); - switch (newStatusUpdate) { - case TaskStatus.complete: - final result = await stitchChunks(); - parallelTaskStatusUpdateCompleter.complete(TaskStatusUpdate(task, - result, null, responseBody, responseHeaders, responseStatusCode)); - break; - - case TaskStatus.failed: - taskException = update.exception; - responseBody = update.responseBody; - cancelAllChunkTasks(sendPort); - parallelTaskStatusUpdateCompleter.complete(TaskStatusUpdate( - task, - TaskStatus.failed, - taskException, - responseBody, - responseHeaders, - responseStatusCode)); - break; - - case TaskStatus.notFound: - responseBody = update.responseBody; - cancelAllChunkTasks(sendPort); - parallelTaskStatusUpdateCompleter.complete(TaskStatusUpdate( - task, - TaskStatus.notFound, - null, - responseBody, - responseHeaders, - responseStatusCode)); - 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 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(); - 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 createChunks( - ParallelDownloadTask task, Map 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'); - } -} diff --git a/lib/services/background_downloader/src/downloader/upload_isolate.dart b/lib/services/background_downloader/src/downloader/upload_isolate.dart deleted file mode 100644 index 035d568..0000000 --- a/lib/services/background_downloader/src/downloader/upload_isolate.dart +++ /dev/null @@ -1,217 +0,0 @@ -// ignore_for_file: depend_on_referenced_packages - -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:isolate'; - -import 'package:mangayomi/services/background_downloader/src/exceptions.dart'; -import 'package:http/http.dart' as http; -import 'package:path/path.dart' as p; - -import '../models.dart'; -import '../task.dart'; -import 'package:mangayomi/services/background_downloader/src/downloader/downloader_http_client.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 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 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(); - var resultStatus = TaskStatus.failed; - try { - final client = DownloaderHttpClient.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; - request.headers['Content-Disposition'] = - 'attachment; filename="${task.filename}"'; - // initiate the request and handle completion async - final requestCompleter = Completer(); - var transferBytesResult = TaskStatus.failed; - client.send(request).then((response) async { - // request completed, so send status update and finish - resultStatus = transferBytesResult == TaskStatus.complete && - !okResponses.contains(response.statusCode) - ? TaskStatus.failed - : transferBytesResult; - responseBody = await responseContent(response); - responseHeaders = response.headers; - responseStatusCode = response.statusCode; - taskException ??= TaskHttpException( - responseBody?.isNotEmpty == true - ? responseBody! - : response.reasonPhrase ?? 'Invalid HTTP response', - response.statusCode); - if (response.statusCode == 404) { - resultStatus = TaskStatus.notFound; - } - requestCompleter.complete(); - }); - // send the bytes to the request sink - final inStream = inFile.openRead(); - 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 multipartUpload( - UploadTask task, String filePath, SendPort sendPort) async { - // field portion of the multipart, all in one string - // multiple values should be encoded as '"value1", "value2", ...' - final multiValueRegEx = RegExp(r'^(?:"[^"]+"\s*,\s*)+"[^"]+"$'); - var fieldsString = ''; - for (var entry in task.fields.entries) { - if (multiValueRegEx.hasMatch(entry.value)) { - // extract multiple values from entry.value - for (final match in RegExp(r'"([^"]+)"').allMatches(entry.value)) { - fieldsString += fieldEntry(entry.key, match.group(1) ?? 'error'); - } - } else { - fieldsString += - fieldEntry(entry.key, entry.value); // single value for key - } - } - // File portion of the multi-part - // Assumes list of files. If only one file, that becomes a list of length one. - // For each file, determine contentDispositionString, contentTypeString - // and file length, so that we can calculate total size of upload - const separator = '$lineFeed--$boundary$lineFeed'; // between files - const terminator = '$lineFeed--$boundary--$lineFeed'; // after last file - final filesData = filePath.isNotEmpty - ? [(task.fileField, filePath, task.mimeType)] // one file Upload case - : await task.extractFilesData(); // MultiUpload case - final contentDispositionStrings = []; - final contentTypeStrings = []; - final fileLengths = []; - 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( - 0, (sum, string) => sum + lengthInBytes(string)) + - contentTypeStrings.fold(0, (sum, string) => sum + string.length) + - fileLengths.fold(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 = DownloaderHttpClient.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); - responseHeaders = response.headers; - responseStatusCode = response.statusCode; - taskException ??= TaskHttpException( - responseBody?.isNotEmpty == true - ? responseBody! - : response.reasonPhrase ?? 'Invalid HTTP response', - response.statusCode); - if (response.statusCode == 404) { - resultStatus = TaskStatus.notFound; - } - requestCompleter.complete(); - }); - - // write fields - request.sink.add(utf8.encode('$fieldsString--$boundary$lineFeed')); - // write each file - for (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; -} diff --git a/lib/services/background_downloader/src/exceptions.dart b/lib/services/background_downloader/src/exceptions.dart deleted file mode 100644 index 3513cdb..0000000 --- a/lib/services/background_downloader/src/exceptions.dart +++ /dev/null @@ -1,120 +0,0 @@ -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] - factory TaskException.fromJson(Map json) { - final typeString = json['type'] as String? ?? 'TaskException'; - final exceptionType = _exceptions[typeString]; - final description = json['description'] as String? ?? ''; - if (exceptionType != null) { - if (typeString != 'TaskHttpException') { - return exceptionType(description); - } else { - final httpResponseCode = - (json['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 toJson() => - {'type': exceptionType, 'description': description}; - - /// Return JSON String representing object - String toJsonString() => jsonEncode(toJson()); - - @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 toJson() => - {...super.toJson(), 'httpResponseCode': httpResponseCode}; - - @override - String toString() { - return '$exceptionType, response code $httpResponseCode: $description'; - } -} diff --git a/lib/services/background_downloader/src/file_downloader.dart b/lib/services/background_downloader/src/file_downloader.dart deleted file mode 100644 index 1814685..0000000 --- a/lib/services/background_downloader/src/file_downloader.dart +++ /dev/null @@ -1,935 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:math'; -import 'package:mangayomi/services/background_downloader/background_downloader.dart'; -import 'package:flutter/foundation.dart'; -import 'package:http/http.dart' as http; -import 'base_downloader.dart'; -import 'localstore/localstore.dart'; -import 'package:mangayomi/services/background_downloader/src/downloader/downloader_http_client.dart'; - -/// Provides access to all functions of the plugin in a single place. -interface class FileDownloader { - static FileDownloader? _singleton; - - /// If no group is specified the default group name will be used - static const defaultGroup = 'default'; - - /// Special group name for tasks that download a chunk, as part of a - /// [ParallelDownloadTask] - static String get chunkGroup => BaseDownloader.chunkGroup; - - /// Database where tracked tasks are stored. - /// - /// Activate tracking by calling [trackTasks], and access the records in the - /// database via this [database] object. - late final Database database; - - late final BaseDownloader _downloader; - - /// Do not use: for testing only - @visibleForTesting - BaseDownloader get downloaderForTesting => _downloader; - - factory FileDownloader({PersistentStorage? persistentStorage}) { - assert( - _singleton == null || persistentStorage == null, - 'You can only supply a persistentStorage on the very first call to ' - 'FileDownloader()'); - _singleton ??= FileDownloader._internal( - persistentStorage ?? LocalStorePersistentStorage(), - ); - return _singleton!; - } - - FileDownloader._internal(PersistentStorage persistentStorage) { - database = Database(persistentStorage); - _downloader = BaseDownloader.instance(persistentStorage, database); - } - - /// True when initialization is complete and downloader ready for use - Future get ready => _downloader.ready; - - /// Stream of [TaskUpdate] updates for downloads that do - /// not have a registered callback - Stream get updates => _downloader.updates.stream; - - /// Configures the downloader - /// - /// Configuration is either a single configItem or a list of configItems. - /// Each configItem is a (String, dynamic) where the String is the config - /// type and 'dynamic' can be any appropriate parameter, including another Record. - /// [globalConfig] is routed to every platform, whereas the platform specific - /// ones only get routed to that platform, after the global configs have - /// completed. - /// If a config type appears more than once, they will all be executed in order, - /// with [globalConfig] executed before the platform-specific config. - /// - /// Returns a list of (String, String) which is the config type and a response - /// which is empty if OK, 'not implemented' if the item could not be recognized and - /// processed, or may contain other error/warning information - /// - /// Please see [CONFIG.md](https://github.com/781flyingdutchman/background_downloader/blob/main/CONFIG.md) - /// for more information - Future> configure( - {dynamic globalConfig, - dynamic androidConfig, - dynamic iOSConfig, - dynamic desktopConfig}) => - _downloader.configure( - globalConfig: globalConfig, - androidConfig: androidConfig, - iOSConfig: iOSConfig, - desktopConfig: desktopConfig); - - /// Register status or progress callbacks to monitor download progress, and - /// [TaskNotificationTapCallback] to respond to user tapping a notification. - /// - /// Status callbacks are called only when the state changes, while - /// progress callbacks are called to inform of intermediate progress. - /// - /// Note that callbacks will be called based on a task's [updates] - /// property, which defaults to status change callbacks only. To also get - /// progress updates make sure to register a [TaskProgressCallback] and - /// set the task's [updates] property to [Updates.progress] or - /// [Updates.statusAndProgress]. - /// - /// For notification callbacks, make sure your AndroidManifest includes - /// android:launchMode="singleTask" to ensure proper behavior when a - /// notification is tapped. - /// - /// Different callbacks can be set for different groups, and the group - /// can be passed on with the [Task] to ensure the - /// appropriate callbacks are called for that group. - /// For the `taskNotificationTapCallback` callback, the `defaultGroup` callback - /// is used when calling 'convenience' functions like `FileDownloader().download` - /// - /// The call returns the [FileDownloader] to make chaining easier - FileDownloader registerCallbacks( - {String group = defaultGroup, - TaskStatusCallback? taskStatusCallback, - TaskProgressCallback? taskProgressCallback, - TaskNotificationTapCallback? taskNotificationTapCallback}) { - assert( - taskStatusCallback != null || - taskProgressCallback != null || - taskNotificationTapCallback != null, - 'Must provide at least one callback'); - if (taskStatusCallback != null) { - _downloader.groupStatusCallbacks[group] = taskStatusCallback; - } - if (taskProgressCallback != null) { - _downloader.groupProgressCallbacks[group] = taskProgressCallback; - } - if (taskNotificationTapCallback != null) { - _downloader.groupNotificationTapCallbacks[group] = - taskNotificationTapCallback; - } - return this; - } - - /// Unregister a previously registered [TaskStatusCallback], [TaskProgressCallback] - /// or [TaskNotificationTapCallback]. - /// - /// [group] defaults to the [FileDownloader.defaultGroup] - /// If [callback] is null, all callbacks for the [group] are unregistered - FileDownloader unregisterCallbacks( - {String group = defaultGroup, Function? callback}) { - if (callback != null) { - // remove specific callback - if (_downloader.groupStatusCallbacks[group] == callback) { - _downloader.groupStatusCallbacks.remove(group); - } - if (_downloader.groupProgressCallbacks[group] == callback) { - _downloader.groupProgressCallbacks.remove(group); - } - if (_downloader.groupNotificationTapCallbacks[group] == callback) { - _downloader.groupNotificationTapCallbacks.remove(group); - } - } else { - // remove all callbacks related to group - _downloader.groupStatusCallbacks.remove(group); - _downloader.groupProgressCallbacks.remove(group); - _downloader.groupNotificationTapCallbacks.remove(group); - } - return this; - } - - /// Adds the [taskQueue] to this downloader - /// - /// Every [TaskQueue] will receive [TaskQueue.taskFinished] for - /// every task that has reached a final state - void addTaskQueue(TaskQueue taskQueue) => - _downloader.taskQueues.add(taskQueue); - - /// Removes [taskQueue] and return true if successful - bool removeTaskQueue(TaskQueue taskQueue) => - _downloader.taskQueues.remove(taskQueue); - - /// List of connected [TaskQueue]s - List get taskQueues => _downloader.taskQueues; - - /// Enqueue a new [Task] - /// - /// Returns true if successfully enqueued. A new task will also generate - /// a [TaskStatus.enqueued] update to the registered callback, - /// if requested by its [updates] property - /// - /// Use [enqueue] instead of the convenience functions (like - /// [download] and [upload]) if: - /// - your download/upload is likely to take long and may require - /// running in the background - /// - you want to monitor tasks centrally, via a listener - /// - you want more detailed progress information - /// (e.g. file size, network speed, time remaining) - Future enqueue(Task task) => _downloader.enqueue(task); - - /// Download a file and return the final [TaskStatusUpdate] - /// - /// Different from [enqueue], this method returns a [Future] that completes - /// when the file has been downloaded, or an error has occurred. - /// While it uses the same download mechanism as [enqueue], - /// and will execute the download also when - /// the app moves to the background, it is meant for downloads that are - /// awaited while the app is in the foreground. - /// - /// Optional callbacks for status and progress updates may be - /// added. These function only take a [TaskStatus] or [double] argument as - /// the task they refer to is expected to be captured in the closure for - /// this call. - /// For example `Downloader.download(task, onStatus: (status) =>` - /// `print('Status for ${task.taskId} is $status);` - /// - /// An optional callback [onElapsedTime] will be called at regular intervals - /// (defined by [elapsedTimeInterval], which defaults to 5 seconds) with a - /// single argument that is the elapsed time since the call to [download]. - /// This can be used to trigger UI warnings (e.g. 'this is taking rather long') - /// or to cancel the task if it does not complete within a desired time. - /// For performance reasons the [elapsedTimeInterval] should not be set to - /// a value less than one second. - /// The [onElapsedTime] callback should not be used to indicate progress. For - /// that, use the [onProgress] callback. - /// - /// Use [enqueue] instead of [download] if: - /// - your download/upload is likely to take long and may require - /// running in the background - /// - you want to monitor tasks centrally, via a listener - /// - you want more detailed progress information - /// (e.g. file size, network speed, time remaining) - Future download(DownloadTask task, - {void Function(TaskStatus)? onStatus, - void Function(double)? onProgress, - void Function(Duration)? onElapsedTime, - Duration? elapsedTimeInterval}) => - _downloader.enqueueAndAwait(task, - onStatus: onStatus, - onProgress: onProgress, - onElapsedTime: onElapsedTime, - elapsedTimeInterval: elapsedTimeInterval); - - /// Upload a file and return the final [TaskStatusUpdate] - /// - /// Different from [enqueue], this method returns a [Future] that completes - /// when the file has been uploaded, or an error has occurred. - /// While it uses the same upload mechanism as [enqueue], - /// and will execute the upload also when - /// the app moves to the background, it is meant for uploads that are - /// awaited while the app is in the foreground. - /// - /// Optional callbacks for status and progress updates may be - /// added. These function only take a [TaskStatus] or [double] argument as - /// the task they refer to is expected to be captured in the closure for - /// this call. - /// For example `Downloader.upload(task, onStatus: (status) =>` - /// `print('Status for ${task.taskId} is $status);` - /// - /// An optional callback [onElapsedTime] will be called at regular intervals - /// (defined by [elapsedTimeInterval], which defaults to 5 seconds) with a - /// single argument that is the elapsed time since the call to [upload]. - /// This can be used to trigger UI warnings (e.g. 'this is taking rather long') - /// or to cancel the task if it does not complete within a desired time. - /// For performance reasons the [elapsedTimeInterval] should not be set to - /// a value less than one second. - /// The [onElapsedTime] callback should not be used to indicate progress. For - /// that, use the [onProgress] callback. - /// - /// Note that the task's [group] is ignored and will be replaced with an - /// internal group name 'await' to track status - /// - /// Use [enqueue] instead of [upload] if: - /// - your download/upload is likely to take long and may require - /// running in the background - /// - you want to monitor tasks centrally, via a listener - /// - you want more detailed progress information - /// (e.g. file size, network speed, time remaining) - Future upload(UploadTask task, - {void Function(TaskStatus)? onStatus, - void Function(double)? onProgress, - void Function(Duration)? onElapsedTime, - Duration? elapsedTimeInterval}) => - _downloader.enqueueAndAwait(task, - onStatus: onStatus, - onProgress: onProgress, - onElapsedTime: onElapsedTime, - elapsedTimeInterval: elapsedTimeInterval); - - /// Transmit data in the [DataTask] and receive the response - /// - /// Different from [enqueue], this method returns a [Future] that completes - /// when the [DataTask] has completed, or an error has occurred. - /// While it uses the same mechanism as [enqueue], - /// and will execute the task also when - /// the app moves to the background, it is meant for data tasks that are - /// awaited while the app is in the foreground. - /// - /// [onStatus] is an optional callback for status updates - /// - /// An optional callback [onElapsedTime] will be called at regular intervals - /// (defined by [elapsedTimeInterval], which defaults to 5 seconds) with a - /// single argument that is the elapsed time since the call to [transmit]. - /// This can be used to trigger UI warnings (e.g. 'this is taking rather long') - /// For performance reasons the [elapsedTimeInterval] should not be set to - /// a value less than one second. - Future transmit(DataTask task, - {void Function(TaskStatus)? onStatus, - void Function(Duration)? onElapsedTime, - Duration? elapsedTimeInterval}) => - _downloader.enqueueAndAwait(task, - onStatus: onStatus, - onElapsedTime: onElapsedTime, - elapsedTimeInterval: elapsedTimeInterval); - - /// Enqueues a list of files to download and returns when all downloads - /// have finished (successfully or otherwise). The returned value is a - /// [Batch] object that contains the original [tasks], the - /// [results] and convenience getters to filter successful and failed results. - /// - /// If an optional [batchProgressCallback] function is provided, it will be - /// called upon completion (successfully or otherwise) of each task in the - /// batch, with two parameters: the number of succeeded and the number of - /// failed tasks. The callback can be used, for instance, to show a progress - /// indicator for the batch, where - /// double percent_complete = (succeeded + failed) / tasks.length - /// - /// To also monitor status and/or progress for each task in the batch, provide - /// a [taskStatusCallback] and/or [taskProgressCallback], which will be used - /// for each task in the batch. - /// - /// An optional callback [onElapsedTime] will be called at regular intervals - /// (defined by [elapsedTimeInterval], which defaults to 5 seconds) with a - /// single argument that is the elapsed time since the call to [downloadBatch]. - /// This can be used to trigger UI warnings (e.g. 'this is taking rather long') - /// or to cancel the task if it does not complete within a desired time. - /// For performance reasons the [elapsedTimeInterval] should not be set to - /// a value less than one second. - /// The [onElapsedTime] callback should not be used to indicate progress. - /// - /// Note that to allow for special processing of tasks in a batch, the task's - /// [Task.group] and [Task.updates] value will be modified when enqueued, and - /// those modified tasks are returned as part of the [Batch] - /// object. - Future downloadBatch(final List tasks, - {BatchProgressCallback? batchProgressCallback, - TaskStatusCallback? taskStatusCallback, - TaskProgressCallback? taskProgressCallback, - void Function(Duration)? onElapsedTime, - Duration? elapsedTimeInterval}) => - _downloader.enqueueAndAwaitBatch(tasks, - batchProgressCallback: batchProgressCallback, - taskStatusCallback: taskStatusCallback, - taskProgressCallback: taskProgressCallback, - onElapsedTime: onElapsedTime, - elapsedTimeInterval: elapsedTimeInterval); - - /// Enqueues a list of files to upload and returns when all uploads - /// have finished (successfully or otherwise). The returned value is a - /// [Batch] object that contains the original [tasks], the - /// [results] and convenience getters to filter successful and failed results. - /// - /// If an optional [batchProgressCallback] function is provided, it will be - /// called upon completion (successfully or otherwise) of each task in the - /// batch, with two parameters: the number of succeeded and the number of - /// failed tasks. The callback can be used, for instance, to show a progress - /// indicator for the batch, where - /// double percent_complete = (succeeded + failed) / tasks.length - /// - /// To also monitor status and/or progress for each task in the batch, provide - /// a [taskStatusCallback] and/or [taskProgressCallback], which will be used - /// for each task in the batch. - /// - /// An optional callback [onElapsedTime] will be called at regular intervals - /// (defined by [elapsedTimeInterval], which defaults to 5 seconds) with a - /// single argument that is the elapsed time since the call to [uploadBatch]. - /// This can be used to trigger UI warnings (e.g. 'this is taking rather long') - /// or to cancel the task if it does not complete within a desired time. - /// For performance reasons the [elapsedTimeInterval] should not be set to - /// a value less than one second. - /// The [onElapsedTime] callback should not be used to indicate progress. - /// - /// Note that to allow for special processing of tasks in a batch, the task's - /// [Task.group] and [Task.updates] value will be modified when enqueued, and - /// those modified tasks are returned as part of the [Batch] - /// object. - Future uploadBatch(final List tasks, - {BatchProgressCallback? batchProgressCallback, - TaskStatusCallback? taskStatusCallback, - TaskProgressCallback? taskProgressCallback, - void Function(Duration)? onElapsedTime, - Duration? elapsedTimeInterval}) => - _downloader.enqueueAndAwaitBatch(tasks, - batchProgressCallback: batchProgressCallback, - taskStatusCallback: taskStatusCallback, - taskProgressCallback: taskProgressCallback, - onElapsedTime: onElapsedTime, - elapsedTimeInterval: elapsedTimeInterval); - - /// Resets the downloader by cancelling all ongoing tasks within - /// the provided [group] - /// - /// Returns the number of tasks cancelled. Every canceled task wil emit a - /// [TaskStatus.canceled] update to the registered callback, if - /// requested - /// - /// This method acts on a [group] of tasks. If omitted, the [defaultGroup] - /// is used, which is the group used when you [enqueue] a task - Future reset({String group = defaultGroup}) => _downloader.reset(group); - - /// Returns a list of taskIds of all tasks currently active in this [group] - /// - /// Active means enqueued or running, and if [includeTasksWaitingToRetry] is - /// true also tasks that are waiting to be retried - /// - /// This method acts on a [group] of tasks. If omitted, the [defaultGroup] - /// is used, which is the group used when you [enqueue] a task - Future> allTaskIds( - {String group = defaultGroup, - bool includeTasksWaitingToRetry = true}) async => - (await allTasks( - group: group, - includeTasksWaitingToRetry: includeTasksWaitingToRetry)) - .map((task) => task.taskId) - .toList(); - - /// Returns a list of all tasks currently active in this [group] - /// - /// Active means enqueued or running, and if [includeTasksWaitingToRetry] is - /// true also tasks that are waiting to be retried - /// - /// This method acts on a [group] of tasks. If omitted, the [defaultGroup] - /// is used, which is the group used when you [enqueue] a task. - Future> allTasks( - {String group = defaultGroup, - bool includeTasksWaitingToRetry = true}) => - _downloader.allTasks(group, includeTasksWaitingToRetry); - - /// Returns true if tasks in this [group] are finished - /// - /// Finished means "not active", i.e. no tasks are enqueued or running, - /// and if [includeTasksWaitingToRetry] is true (the default), no tasks are - /// waiting to be retried. - /// Finished does not mean that all tasks completed successfully. - /// - /// This method acts on a [group] of tasks. If omitted, the [defaultGroup] - /// is used, which is the group used when you [enqueue] a task. - /// - /// If an [ignoreTask] is provided, it will be excluded from the test. This - /// allows you to test for [tasksFinished] within the status update callback - /// for a task that just finished. In that situation, that task may still - /// be returned by the platform as 'active', but you already know it is not. - /// Calling [tasksFinished] while passing that just-finished task will ensure - /// a proper test in that situation. - Future tasksFinished( - {String group = defaultGroup, - bool includeTasksWaitingToRetry = true, - String? ignoreTaskId}) async { - final tasksInProgress = await allTasks( - group: group, includeTasksWaitingToRetry: includeTasksWaitingToRetry); - if (ignoreTaskId != null) { - tasksInProgress.removeWhere((task) => task.taskId == ignoreTaskId); - } - return tasksInProgress.isEmpty; - } - - /// Cancel all tasks matching the taskIds in the list - /// - /// Every canceled task wil emit a [TaskStatus.canceled] update to - /// the registered callback, if requested - Future cancelTasksWithIds(List taskIds) => - _downloader.cancelTasksWithIds(taskIds); - - /// Cancel this task - /// - /// The task will emit a [TaskStatus.canceled] update to - /// the registered callback, if requested - Future cancelTaskWithId(String taskId) => cancelTasksWithIds([taskId]); - - /// Return [Task] for the given [taskId], or null - /// if not found. - /// - /// Only running tasks are guaranteed to be returned, but returning a task - /// does not guarantee that the task is still running. To keep track of - /// the status of tasks, use a [TaskStatusCallback] - Future taskForId(String taskId) => _downloader.taskForId(taskId); - - /// Activate tracking for tasks in this [group] - /// - /// All subsequent tasks in this group will be recorded in persistent storage. - /// Use the [FileDownloader.database] to get or remove [TaskRecord] objects, - /// which contain a [Task], its [TaskStatus] and a [double] for progress. - /// - /// If [markDownloadedComplete] is true (default) then all tasks in the - /// database that are marked as not yet [TaskStatus.complete] will be set to - /// [TaskStatus.complete] if the target file for that task exists. - /// They will also emit [TaskStatus.complete] and [progressComplete] to - /// their registered listener or callback. - /// This is a convenient way to capture downloads that have completed while - /// the app was suspended: on app startup, immediately register your - /// listener or callbacks, and call [trackTasks] for each group. - /// - /// Returns the [FileDownloader] for easy chaining - Future trackTasksInGroup(String group, - {bool markDownloadedComplete = true}) async { - await _downloader.trackTasks(group, markDownloadedComplete); - return this; - } - - /// Activate tracking for all tasks - /// - /// All subsequent tasks will be recorded in persistent storage. - /// Use the [FileDownloader.database] to get or remove [TaskRecord] objects, - /// which contain a [Task], its [TaskStatus] and a [double] for progress. - /// - /// If [markDownloadedComplete] is true (default) then all tasks in the - /// database that are marked as not yet [TaskStatus.complete] will be set to - /// [TaskStatus.complete] if the target file for that task exists. - /// They will also emit [TaskStatus.complete] and [progressComplete] to - /// their registered listener or callback. - /// This is a convenient way to capture downloads that have completed while - /// the app was suspended: on app startup, immediately register your - /// listener or callbacks, and call [trackTasks]. - /// - /// Returns the [FileDownloader] for easy chaining - Future trackTasks( - {bool markDownloadedComplete = true}) async { - await _downloader.trackTasks(null, markDownloadedComplete); - return this; - } - - /// Wakes up the FileDownloader from possible background state, triggering - /// a stream of updates that may have been processed while in the background, - /// and have not yet reached the callbacks or listener - /// - /// Calling this method multiple times has no effect. - Future resumeFromBackground() => - _downloader.retrieveLocallyStoredData(); - - /// Returns true if task can be resumed on pause - /// - /// This future only completes once the task is running and has received - /// information from the server to determine whether resume is possible, or - /// if the task fails and resume is possible - Future taskCanResume(Task task) => _downloader.taskCanResume(task); - - /// Pause the task - /// - /// Returns true if the pause was attempted successfully. Test the task's - /// status to see if it was executed successfully [TaskStatus.paused] or if - /// it failed after all [TaskStatus.failed] - /// - /// If the [Task.allowPause] field is set to false (default) or if this is - /// a POST request, this method returns false immediately. - Future pause(DownloadTask task) async { - if (task.allowPause && task.post == null) { - return _downloader.pause(task); - } - return false; - } - - /// Resume the task - /// - /// If no resume data is available for this task, the call to [resume] - /// will return false and the task is not resumed. - /// If resume data is available, the call to [resume] will return true, - /// but this does not guarantee that resuming is actually possible, just that - /// the task is now enqueued for resume. - /// If the task is able to resume, it will, otherwise it will restart the - /// task from scratch, or fail. - Future resume(DownloadTask task) => _downloader.resume(task); - - /// Set WiFi requirement globally, based on [requirement]. - /// - /// Affects future tasks and reschedules enqueued, inactive tasks - /// with the new setting. - /// Reschedules running tasks if [rescheduleRunningTasks] is true, - /// otherwise leaves those running with their prior setting - Future requireWiFi(RequireWiFi requirement, - {final rescheduleRunningTasks = true}) => - _downloader.requireWiFi(requirement, rescheduleRunningTasks); - - /// Returns the current global setting for requiring WiFi - Future getRequireWiFiSetting() => - _downloader.getRequireWiFiSetting(); - - /// Configure notification for a single task - /// - /// The configuration determines what notifications are shown, - /// whether a progress bar is shown (Android only), and whether tapping - /// the 'complete' notification opens the downloaded file. - /// - /// [running] is the notification used while the task is in progress - /// [complete] is the notification used when the task completed - /// [error] is the notification used when something went wrong, - /// including pause, failed and notFound status - /// [progressBar] if set will show a progress bar - /// [tapOpensFile] if set will attempt to open the file when the [complete] - /// notification is tapped - /// [groupNotificationId] if set will group all notifications with the same - /// [groupNotificationId] and change the progress bar to number of finished - /// tasks versus total number of tasks in the [groupNotificationId]. - /// Use {numFinished} and {numTotal} tokens in the [TaskNotification.title] - /// and [TaskNotification.body] to substitute. Task-specific substitutions - /// such as {filename} are not valid when using [groupNotificationId]. - /// The [groupNotificationId] is considered [complete] when there are no - /// more tasks running within that group, and at that point the - /// [complete] notification is shown (if configured). If any task in the - /// [groupNotificationId] fails, the [error] notification is shown. - /// The first character of the [groupNotificationId] cannot be '*'. - /// - /// The [TaskNotification] is the actual notification shown for a [Task], and - /// [body] and [title] may contain special strings to substitute display values: - /// {filename} to insert the [Task.filename] - /// {metaData} to insert the [Task.metaData] - /// {displayName} to insert the [Task.displayName] - /// {progress} to insert progress in % - /// {networkSpeed} to insert the network speed in MB/s or kB/s, or '--' if N/A - /// {timeRemaining} to insert the estimated time remaining to complete the task - /// in HH:MM:SS or MM:SS or --:-- if N/A - /// {numFinished} to insert the number of finished tasks in a groupNotification - /// {numFailed} to insert the number of failed tasks in a groupNotification - /// {numTotal} to insert the number of tasks in a groupNotification - /// - /// Actual appearance of notification is dependent on the platform, e.g. - /// on iOS {progress} is not available and ignored (except for groupNotifications) - /// - /// Returns the [FileDownloader] for easy chaining - FileDownloader configureNotificationForTask(Task task, - {TaskNotification? running, - TaskNotification? complete, - TaskNotification? error, - TaskNotification? paused, - bool progressBar = false, - bool tapOpensFile = false, - String groupNotificationId = ''}) { - _downloader.notificationConfigs.add(TaskNotificationConfig( - taskOrGroup: task, - running: running, - complete: complete, - error: error, - paused: paused, - progressBar: progressBar, - tapOpensFile: tapOpensFile, - groupNotificationId: groupNotificationId)); - return this; - } - - /// Configure notification for a group of tasks - /// - /// The configuration determines what notifications are shown, - /// whether a progress bar is shown (Android only), and whether tapping - /// the 'complete' notification opens the downloaded file. - /// - /// [running] is the notification used while the task is in progress - /// [complete] is the notification used when the task completed - /// [error] is the notification used when something went wrong, - /// including pause, failed and notFound status - /// [progressBar] if set will show a progress bar - /// [tapOpensFile] if set will attempt to open the file when the [complete] - /// notification is tapped - /// [groupNotificationId] if set will group all notifications with the same - /// [groupNotificationId] and change the progress bar to number of finished - /// tasks versus total number of tasks in the [groupNotificationId]. - /// Use {numFinished} and {numTotal} tokens in the [TaskNotification.title] - /// and [TaskNotification.body] to substitute. Task-specific substitutions - /// such as {filename} are not valid when using [groupNotificationId]. - /// The [groupNotificationId] is considered [complete] when there are no - /// more tasks running within that group, and at that point the - /// [complete] notification is shown (if configured). If any task in the - /// [groupNotificationId] fails, the [error] notification is shown. - /// The first character of the [groupNotificationId] cannot be '*'. - /// - /// The [TaskNotification] is the actual notification shown for a [Task], and - /// [body] and [title] may contain special strings to substitute display values: - /// {filename} to insert the [Task.filename] - /// {metaData} to insert the [Task.metaData] - /// {displayName} to insert the [Task.displayName] - /// {progress} to insert progress in % - /// {networkSpeed} to insert the network speed in MB/s or kB/s, or '--' if N/A - /// {timeRemaining} to insert the estimated time remaining to complete the task - /// in HH:MM:SS or MM:SS or --:-- if N/A - /// {numFinished} to insert the number of finished tasks in a groupNotification - /// {numFailed} to insert the number of failed tasks in a groupNotification - /// {numTotal} to insert the number of tasks in a groupNotification - /// - /// Actual appearance of notification is dependent on the platform, e.g. - /// on iOS {progress} is not available and ignored (except for groupNotifications) - /// - /// Returns the [FileDownloader] for easy chaining - FileDownloader configureNotificationForGroup(String group, - {TaskNotification? running, - TaskNotification? complete, - TaskNotification? error, - TaskNotification? paused, - bool progressBar = false, - bool tapOpensFile = false, - String groupNotificationId = ''}) { - _downloader.notificationConfigs.add(TaskNotificationConfig( - taskOrGroup: group, - running: running, - complete: complete, - error: error, - paused: paused, - progressBar: progressBar, - tapOpensFile: tapOpensFile, - groupNotificationId: groupNotificationId)); - return this; - } - - /// Configure default task notification - /// - /// The configuration determines what notifications are shown, - /// whether a progress bar is shown (Android only), and whether tapping - /// the 'complete' notification opens the downloaded file. - /// - /// [running] is the notification used while the task is in progress - /// [complete] is the notification used when the task completed - /// [error] is the notification used when something went wrong, - /// including pause, failed and notFound status - /// [progressBar] if set will show a progress bar - /// [tapOpensFile] if set will attempt to open the file when the [complete] - /// notification is tapped - /// [groupNotificationId] if set will group all notifications with the same - /// [groupNotificationId] and change the progress bar to number of finished - /// tasks versus total number of tasks in the [groupNotificationId]. - /// Use {numFinished} and {numTotal} tokens in the [TaskNotification.title] - /// and [TaskNotification.body] to substitute. Task-specific substitutions - /// such as {filename} are not valid when using [groupNotificationId]. - /// The [groupNotificationId] is considered [complete] when there are no - /// more tasks running within that group, and at that point the - /// [complete] notification is shown (if configured). If any task in the - /// [groupNotificationId] fails, the [error] notification is shown. - /// The first character of the [groupNotificationId] cannot be '*'. - /// - /// The [TaskNotification] is the actual notification shown for a [Task], and - /// [body] and [title] may contain special strings to substitute display values: - /// {filename} to insert the [Task.filename] - /// {metaData} to insert the [Task.metaData] - /// {displayName} to insert the [Task.displayName] - /// {progress} to insert progress in % - /// {networkSpeed} to insert the network speed in MB/s or kB/s, or '--' if N/A - /// {timeRemaining} to insert the estimated time remaining to complete the task - /// in HH:MM:SS or MM:SS or --:-- if N/A - /// {numFinished} to insert the number of finished tasks in a groupNotification - /// {numFailed} to insert the number of failed tasks in a groupNotification - /// {numTotal} to insert the number of tasks in a groupNotification - /// - /// Actual appearance of notification is dependent on the platform, e.g. - /// on iOS {progress} is not available and ignored (except for groupNotifications) - /// - /// Returns the [FileDownloader] for easy chaining - FileDownloader configureNotification( - {TaskNotification? running, - TaskNotification? complete, - TaskNotification? error, - TaskNotification? paused, - bool progressBar = false, - bool tapOpensFile = false, - String groupNotificationId = ''}) { - _downloader.notificationConfigs.add(TaskNotificationConfig( - taskOrGroup: null, - running: running, - complete: complete, - error: error, - paused: paused, - progressBar: progressBar, - tapOpensFile: tapOpensFile, - groupNotificationId: groupNotificationId)); - return this; - } - - /// Perform a server request for this [request] - /// - /// A server request returns an [http.Response] object that includes - /// the [body] as String, the [bodyBytes] as [UInt8List] and the [json] - /// representation if available. - /// It also contains the [statusCode] and [reasonPhrase] that may indicate - /// an error, and several other fields that may be useful. - /// A local error (e.g. a SocketException) will yield [statusCode] 499, with - /// details in the [reasonPhrase] - /// - /// The request will abide by the [retries] set on the [request], and set - /// [headers] included in the [request] - /// - /// The [http.Client] object used for this request is the [httpClient] field of - /// the downloader. If not set, the default [http.Client] will be used. - /// The request is executed on an Isolate, to ensure minimal interference - /// with the main Isolate - Future request(Request request) { - return compute(_doRequest, ( - request, - DownloaderHttpClient.requestTimeout, - DownloaderHttpClient.proxy, - DownloaderHttpClient.bypassTLSCertificateValidation - )); - } - - /// Move the file represented by the [task] to a shared storage - /// [destination] and potentially a [directory] within that destination. If - /// the [mimeType] is not provided we will attempt to derive it from the - /// [Task.filePath] extension - /// - /// Returns the path to the stored file, or null if not successful - /// - /// NOTE: on iOS, using [destination] [SharedStorage.images] or - /// [SharedStorage.video] adds the photo or video file to the Photos - /// library. This requires the user to grant permission, and requires the - /// "NSPhotoLibraryAddUsageDescription" key to be set in Info.plist. The - /// returned value is NOT a filePath but an identifier. If the full filepath - /// is required, follow the [moveToSharedStorage] call with a call to - /// [pathInSharedStorage], passing the identifier obtained from the call - /// to [moveToSharedStorage] as the filePath parameter. This requires the user to - /// grant additional permissions, and requires the "NSPhotoLibraryUsageDescription" - /// key to be set in Info.plist. The returned value is the actual file path - /// of the photo or video in the Photos Library. - /// - /// Platform-dependent, not consistent across all platforms - Future moveToSharedStorage( - DownloadTask task, - SharedStorage destination, { - String directory = '', - String? mimeType, - }) async => - moveFileToSharedStorage(await task.filePath(), destination, - directory: directory, mimeType: mimeType); - - /// Move the file represented by [filePath] to a shared storage - /// [destination] and potentially a [directory] within that destination. If - /// the [mimeType] is not provided we will attempt to derive it from the - /// [filePath] extension - /// - /// Returns the path to the stored file, or null if not successful - /// NOTE: on iOS, using [destination] [SharedStorage.images] or - /// [SharedStorage.video] adds the photo or video file to the Photos - /// library. This requires the user to grant permission, and requires the - /// "NSPhotoLibraryAddUsageDescription" key to be set in Info.plist. The - /// returned value is NOT a filePath but an identifier. If the full filepath - /// is required, follow the [moveToSharedStorage] call with a call to - /// [pathInSharedStorage], passing the identifier obtained from the call - /// to [moveToSharedStorage] as the filePath parameter. This requires the user to - /// grant additional permissions, and requires the "NSPhotoLibraryUsageDescription" - /// key to be set in Info.plist. The returned value is the actual file path - /// of the photo or video in the Photos Library. - /// - /// Platform-dependent, not consistent across all platforms - Future moveFileToSharedStorage( - String filePath, - SharedStorage destination, { - String directory = '', - String? mimeType, - }) async => - _downloader.moveToSharedStorage( - filePath, destination, directory, mimeType); - - /// Returns the filePath to the file represented by [filePath] in shared - /// storage [destination] and potentially a [directory] within that - /// destination. - /// - /// Returns the path to the stored file, or null if not successful - /// - /// See the documentation for [moveToSharedStorage] for special use case - /// on iOS for .images and .video - /// - /// Platform-dependent, not consistent across all platforms - Future pathInSharedStorage( - String filePath, SharedStorage destination, - {String directory = ''}) async => - _downloader.pathInSharedStorage(filePath, destination, directory); - - /// Open the file represented by [task] or [filePath] using the application - /// available on the platform. - /// - /// [mimeType] may override the mimetype derived from the file extension, - /// though implementation depends on the platform and may not always work. - /// - /// Returns true if an application was launched successfully - Future openFile({Task? task, String? filePath, String? mimeType}) { - assert(task != null || filePath != null, 'Task or filePath must be set'); - assert(!(task != null && filePath != null), - 'Either task or filePath must be set, not both'); - return _downloader.openFile(task, filePath, mimeType); - } - - /// Return the platform version as a String - /// - /// On Android this is the API integer, e.g. "33" - /// On iOS this is the iOS version, e.g. "16.1" - /// On desktop this is a description of the OS version, not parsable - Future platformVersion() => _downloader.platformVersion(); - - /// Closes the [updates] stream and re-initializes the [StreamController] - /// such that the stream can be listened to again - Future resetUpdates() => _downloader.resetUpdatesStreamController(); - - /// Destroy the [FileDownloader]. Subsequent use requires initialization - void destroy() { - _downloader.destroy(); - Localstore.instance.clearCache(); - } -} - -/// Performs the actual server request, with retries -/// -/// This function is run on an Isolate to ensure performance on the main -/// Isolate is not affected -Future _doRequest( - (Request, Duration?, Map, bool) params) async { - final (request, requestTimeout, proxy, bypassTLSCertificateValidation) = - params; - - DownloaderHttpClient.setHttpClient( - requestTimeout, proxy, bypassTLSCertificateValidation); - - final client = DownloaderHttpClient.httpClient; - var response = http.Response('', 499, - reasonPhrase: 'Not attempted'); // dummy to start with - while (request.retriesRemaining >= 0) { - try { - response = await switch (request.httpRequestMethod) { - 'GET' => client.get(Uri.parse(request.url), headers: request.headers), - 'POST' => client.post(Uri.parse(request.url), - headers: request.headers, body: request.post), - 'HEAD' => client.head(Uri.parse(request.url), headers: request.headers), - 'PUT' => client.put(Uri.parse(request.url), headers: request.headers), - 'DELETE' => - client.delete(Uri.parse(request.url), headers: request.headers), - 'PATCH' => - client.patch(Uri.parse(request.url), headers: request.headers), - _ => Future.value(response) - }; - if ([200, 201, 202, 203, 204, 205, 206, 404] - .contains(response.statusCode)) { - return response; - } - } catch (e) { - response = http.Response('', 499, reasonPhrase: e.toString()); - } - // error, retry if allowed - request.decreaseRetriesRemaining(); - if (request.retriesRemaining < 0) { - return response; // final response with error - } - final waitTime = Duration( - seconds: pow(2, (request.retries - request.retriesRemaining)).toInt()); - await Future.delayed(waitTime); - } - throw ArgumentError('Request to ${request.url} had no retries remaining'); -} diff --git a/lib/services/background_downloader/src/localstore/collection_ref.dart b/lib/services/background_downloader/src/localstore/collection_ref.dart deleted file mode 100644 index f2f018e..0000000 --- a/lib/services/background_downloader/src/localstore/collection_ref.dart +++ /dev/null @@ -1,102 +0,0 @@ -part of 'localstore.dart'; - -/// A [CollectionRef] object can be used for adding documents, getting -/// [DocumentRef]s, and querying for documents. -final class CollectionRef implements CollectionRefImpl { - String _id; - - /// A string representing the path of the referenced document (relative to the - /// root of the database). - String get path => _path; - - String _path = ''; - - DocumentRef? _delegate; - - CollectionRef? _parent; - - List? _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 = {}; - - /// Returns an instance using the default [CollectionRef]. - factory CollectionRef( - String id, [ - CollectionRef? parent, - DocumentRef? delegate, - 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> get stream => _utils.stream(path, _conditions); - - @override - Future?> 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 = []; - void addCondition(dynamic field, String operator, dynamic value) { - List condition; - - condition = [field, operator, value]; - conditions.add(condition); - } - - if (isEqualTo != null) addCondition(field, '==', isEqualTo); - - _conditions = conditions; - - return this; - } - - @override - Future 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); - } -} diff --git a/lib/services/background_downloader/src/localstore/collection_ref_impl.dart b/lib/services/background_downloader/src/localstore/collection_ref_impl.dart deleted file mode 100644 index 36e12b0..0000000 --- a/lib/services/background_downloader/src/localstore/collection_ref_impl.dart +++ /dev/null @@ -1,32 +0,0 @@ -part of 'localstore.dart'; - -/// The interface that other CollectionRef must extend. -abstract class CollectionRefImpl { - /// Returns a `DocumentRef` with the provided id. - /// - /// If no [id] is provided, an auto-generated ID is used. - /// - /// The unique key generated is prefixed with a client-generated timestamp - /// so that the resulting list will be chronologically-sorted. - DocumentRef doc([String? id]); - - /// Notifies of query results at this collection. - Stream> get stream; - - /// Fetch the documents for this collection - Future?> 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 delete(); -} diff --git a/lib/services/background_downloader/src/localstore/document_ref.dart b/lib/services/background_downloader/src/localstore/document_ref.dart deleted file mode 100644 index 1d680b0..0000000 --- a/lib/services/background_downloader/src/localstore/document_ref.dart +++ /dev/null @@ -1,71 +0,0 @@ -part of 'localstore.dart'; - -/// A [DocumentRef] refers to a document location in a [Localstore] database -/// and can be used to write, read, or listen to the location. -/// -/// The document at the referenced location may or may not exist. -/// A [DocumentRef] can also be used to create a [CollectionRef] -/// to a subcollection. -final class DocumentRef implements DocumentRefImpl { - String _id; - - /// This document's given ID within the collection. - String get id => _id; - - CollectionRef? _delegate; - - DocumentRef._(this._id, [this._delegate]); - - static final _cache = {}; - - /// 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 _data = {}; - - @override - Future set(Map data, [SetOptions? options]) async { - options ??= SetOptions(); - if (options.merge) { - final output = Map.from(data); - Map? input = _data[id] ?? {}; - output.updateAll((key, value) { - input![key] = value; - }); - _data[id] = input; - } else { - _data[id] = data; - } - _utils.set(_data[id], path); - } - - @override - Future?> 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(); - } -} diff --git a/lib/services/background_downloader/src/localstore/document_ref_impl.dart b/lib/services/background_downloader/src/localstore/document_ref_impl.dart deleted file mode 100644 index cf87238..0000000 --- a/lib/services/background_downloader/src/localstore/document_ref_impl.dart +++ /dev/null @@ -1,20 +0,0 @@ -part of 'localstore.dart'; - -/// The interface that other DocumentRef must extend. -abstract class DocumentRefImpl { - /// Gets a [CollectionRef] for the specified Localstore path. - CollectionRef collection(String path); - - /// Sets data on the document, overwriting any existing data. If the document - /// does not yet exist, it will be created. - /// - /// If [SetOptions] are provided, the data will be merged into an existing - /// document instead of overwriting. - Future set(Map data, [SetOptions? options]); - - /// Reads the document referenced by this [DocumentRef]. - Future?> get(); - - /// Deletes the current document from the collection. - Future delete(); -} diff --git a/lib/services/background_downloader/src/localstore/localstore.dart b/lib/services/background_downloader/src/localstore/localstore.dart deleted file mode 100644 index e164902..0000000 --- a/lib/services/background_downloader/src/localstore/localstore.dart +++ /dev/null @@ -1,20 +0,0 @@ -// ignore_for_file: depend_on_referenced_packages - -library; - -import 'dart:async'; -import 'dart:io'; - -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; -import 'dart:math'; - -import 'utils/html.dart' if (dart.library.io) 'utils/io.dart'; - -part 'collection_ref.dart'; -part 'collection_ref_impl.dart'; -part 'document_ref.dart'; -part 'document_ref_impl.dart'; -part 'set_option.dart'; -part 'localstore_base.dart'; -part 'localstore_impl.dart'; diff --git a/lib/services/background_downloader/src/localstore/localstore_base.dart b/lib/services/background_downloader/src/localstore/localstore_base.dart deleted file mode 100644 index 81c499a..0000000 --- a/lib/services/background_downloader/src/localstore/localstore_base.dart +++ /dev/null @@ -1,32 +0,0 @@ -part of 'localstore.dart'; - -/// The entry point for accessing a [Localstore]. -/// -/// You can get an instance by calling [Localstore.instance], for example: -/// -/// ```dart -/// final db = Localstore.instance; -/// ``` -final class Localstore implements LocalstoreImpl { - final _databaseDirectory = getApplicationSupportDirectory(); - final _delegate = DocumentRef._(''); - static final Localstore _localstore = Localstore._(); - - /// Private initializer - Localstore._(); - - /// Returns an instance using the default [Localstore]. - static Localstore get instance => _localstore; - - Future 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); - } -} diff --git a/lib/services/background_downloader/src/localstore/localstore_impl.dart b/lib/services/background_downloader/src/localstore/localstore_impl.dart deleted file mode 100644 index f045a70..0000000 --- a/lib/services/background_downloader/src/localstore/localstore_impl.dart +++ /dev/null @@ -1,7 +0,0 @@ -part of 'localstore.dart'; - -/// The interface that other Localstore must extend. -abstract class LocalstoreImpl { - /// Gets a [CollectionRef] for the specified Localstore path. - CollectionRef collection(String path); -} diff --git a/lib/services/background_downloader/src/localstore/set_option.dart b/lib/services/background_downloader/src/localstore/set_option.dart deleted file mode 100644 index 646a6a0..0000000 --- a/lib/services/background_downloader/src/localstore/set_option.dart +++ /dev/null @@ -1,14 +0,0 @@ -part of 'localstore.dart'; - -/// An options class that configures the behavior of set() calls in -/// [DocumentRef]. -final class SetOptions { - final bool _merge; - - /// Changes the behavior of a set() call to only replace the values specified - /// in its data argument. - bool get merge => _merge; - - /// Creates a [SetOptions] instance. - SetOptions({bool merge = false}) : _merge = merge; -} diff --git a/lib/services/background_downloader/src/localstore/utils/html.dart b/lib/services/background_downloader/src/localstore/utils/html.dart deleted file mode 100644 index 7276613..0000000 --- a/lib/services/background_downloader/src/localstore/utils/html.dart +++ /dev/null @@ -1,223 +0,0 @@ -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?> get(String path, - [bool? isCollection = false, 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; - final its = SplayTreeMap.of(mapCol); - its.removeWhere((key, value) { - if (value is Map) { - 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; - _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) { - if (data.containsKey(id)) return data[id]; - return null; - } - } - return null; - } - - @override - Future? set(Map data, String path) { - return _writeToStorage(data, path); - } - - @override - Future delete(String path) async { - _deleteFromStorage(path); - } - - @override - Stream> stream(String path, [List? conditions]) { - // ignore: close_sinks - final storage = _storageCache[path] ?? - _storageCache.putIfAbsent( - path, () => StreamController>.broadcast()); - - _initStream(storage, path); - return storage.stream; - } - - Map? _getAll(MapEntry dataCol) { - final items = {}; - try { - final mapCol = json.decode(dataCol.value) as Map; - mapCol.forEach((key, value) { - final data = value as Map; - items[key] = data; - }); - if (items.isEmpty) return null; - return items; - } catch (error) { - rethrow; - } - } - - void _initStream( - StreamController> 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; - mapCol.forEach((key, value) { - final data = value as Map; - storage.add(data); - }); - } - } catch (error) { - rethrow; - } - } - - final _storageCache = >>{}; - - Future _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; - } catch (e) { - return e; - } - } - } - - Future _writeToStorage( - Map 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; - 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>.broadcast()); - - storage.sink.add(data); - } catch (error) { - rethrow; - } - } - - Future _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; - mapCol.remove(id); - html.window.localStorage.update( - key, - (value) => json.encode(mapCol), - ifAbsent: () => dataCol.value, - ); - } - } catch (error) { - rethrow; - } - } - } -} diff --git a/lib/services/background_downloader/src/localstore/utils/io.dart b/lib/services/background_downloader/src/localstore/utils/io.dart deleted file mode 100644 index 266589a..0000000 --- a/lib/services/background_downloader/src/localstore/utils/io.dart +++ /dev/null @@ -1,222 +0,0 @@ -// ignore_for_file: depend_on_referenced_packages - -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 = >>{}; - final _fileCache = {}; - - /// Clears the cache - @override - void clearCache() { - _storageCache.clear(); - _fileCache.clear(); - } - - @override - Future?> get(String path, - [bool? isCollection = false, 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 entries = - dir.listSync(recursive: false).whereType().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) { - 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? set(Map 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> stream(String path, [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?> _getAll(List entries) async { - final items = {}; - 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) { - items[path] = data; - } - } on PathNotFoundException { - // ignore if not found - } - }); - - if (items.isEmpty) return null; - return items; - } - - /// Streams all file in the path - StreamController> _newStream(String path) { - final storage = StreamController>.broadcast(); - _initStream(storage, path); - - return storage; - } - - Future _initStream( - StreamController> storage, - String path, - ) async { - final dbDir = await Localstore.instance.databaseDirectory; - final fullPath = '${dbDir.path}$path'; - final dir = Directory(fullPath); - try { - List entries = - dir.listSync(recursive: false).whereType().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) { - storage.add(data); - } - }); - } - } catch (e) { - return e; - } - } - - Future _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; - return data; - } catch (e) { - return e; - } - } - - Future _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 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)); - } -} diff --git a/lib/services/background_downloader/src/localstore/utils/utils_impl.dart b/lib/services/background_downloader/src/localstore/utils/utils_impl.dart deleted file mode 100644 index 280e337..0000000 --- a/lib/services/background_downloader/src/localstore/utils/utils_impl.dart +++ /dev/null @@ -1,8 +0,0 @@ -abstract class UtilsImpl { - void clearCache(); - Future?> get(String path, - [bool? isCollection = false, List? conditions]); - Future? set(Map data, String path); - Future delete(String path); - Stream> stream(String path, [List? conditions]); -} diff --git a/lib/services/background_downloader/src/models.dart b/lib/services/background_downloader/src/models.dart deleted file mode 100644 index 026c5d4..0000000 --- a/lib/services/background_downloader/src/models.dart +++ /dev/null @@ -1,577 +0,0 @@ -import 'dart:convert'; - -import 'exceptions.dart'; -import 'task.dart'; - -/// Defines a set of possible states which a [Task] can be in. -enum TaskStatus { - /// Task is enqueued on the native platform and waiting to start - /// - /// It may wait for resources, or for an appropriate network to become - /// available before starting the actual download and changing state to - /// `running`. - enqueued, - - /// Task is running, i.e. actively downloading - running, - - /// Task has completed successfully - /// - /// This is a final state - complete, - - /// Task has completed because the url was not found (Http status code 404) - /// - /// This is a final state - notFound, - - /// Task has failed due to an exception - /// - /// This is a final state - failed, - - /// Task has been canceled by the user or the system - /// - /// This is a final state - canceled, - - /// Task failed, and is now waiting to retry - /// - /// The task is held in this state until the exponential backoff time for - /// this retry has passed, and will then be rescheduled on the native - /// platform, switching state to `enqueued` and then `running` - waitingToRetry, - - /// Task is in paused state and may be able to resume - /// - /// To resume a paused Task, call [resumeTaskWithId]. If the resume is - /// possible, status will change to [TaskStatus.running] and continue from - /// there. If resume fails (e.g. because the temp file with the partial - /// download has been deleted by the operating system) status will switch - /// to [TaskStatus.failed] - paused; - - /// True if this state is one of the 'final' states, meaning no more - /// state changes are possible - bool get isFinalState { - switch (this) { - case TaskStatus.complete: - case TaskStatus.notFound: - case TaskStatus.failed: - case TaskStatus.canceled: - return true; - - case TaskStatus.enqueued: - case TaskStatus.running: - case TaskStatus.waitingToRetry: - case TaskStatus.paused: - return false; - } - } - - /// True if this state is not a 'final' state, meaning more - /// state changes are possible - bool get isNotFinalState => !isFinalState; -} - -/// Base directory in which files will be stored, based on their relative -/// path. -/// -/// These correspond to the directories provided by the path_provider package -enum BaseDirectory { - /// As returned by getApplicationDocumentsDirectory() - applicationDocuments, - - /// As returned by getTemporaryDirectory() - temporary, - - /// As returned by getApplicationSupportDirectory() - applicationSupport, - - /// As returned by getApplicationLibrary() on iOS. For other platforms - /// this resolves to the subdirectory 'Library' created in the directory - /// returned by getApplicationSupportDirectory() - applicationLibrary, - - /// System root directory. This allows you to set a path to any directory - /// via [Task.directory]. Only use this if you are certain that this - /// path is stable. on iOS and Android, references to paths within - /// the application's directory structure are *not* stable, and you - /// should use [applicationDocuments], [applicationSupport] or - /// [applicationLibrary] instead to avoid errors. - root -} - -/// Type of updates requested for a task or group of tasks -enum Updates { - /// no status change or progress updates - none, - - /// only status changes - status, - - /// only progress updates while downloading, no status change updates - progress, - - /// Status change updates and progress updates while downloading - statusAndProgress, -} - -/// Signature for a function you can register to be called -/// when the status of a [task] changes. -typedef TaskStatusCallback = void Function(TaskStatusUpdate update); - -/// Signature for a function you can register to be called -/// for every progress change of a [task]. -/// -/// A successfully completed task will always finish with progress 1.0 -/// [TaskStatus.failed] results in progress -1.0 -/// [TaskStatus.canceled] results in progress -2.0 -/// [TaskStatus.notFound] results in progress -3.0 -/// [TaskStatus.waitingToRetry] results in progress -4.0 -/// These constants are available as [progressFailed] etc -typedef TaskProgressCallback = void Function(TaskProgressUpdate update); - -/// Signature for function you can register to be called when a notification -/// is tapped by the user -typedef TaskNotificationTapCallback = void Function( - Task task, NotificationType notificationType); - -/// Signature for a function you can provide to the [downloadBatch] or -/// [uploadBatch] that will be called upon completion of each task -/// in the batch. -/// -/// [succeeded] will count the number of successful downloads, and -/// [failed] counts the number of failed downloads (for any reason). -typedef BatchProgressCallback = void Function(int succeeded, int failed); - -/// Contains tasks and results related to a batch of tasks -class Batch { - final List tasks; - final BatchProgressCallback? batchProgressCallback; - final results = {}; - - Batch(this.tasks, this.batchProgressCallback); - - /// Returns an Iterable with successful tasks in this batch - Iterable get succeeded => results.entries - .where((entry) => entry.value == TaskStatus.complete) - .map((e) => e.key); - - /// Returns the number of successful tasks in this batch - int get numSucceeded => - results.values.where((result) => result == TaskStatus.complete).length; - - /// Returns an Iterable with failed tasks in this batch - Iterable get failed => results.entries - .where((entry) => entry.value != TaskStatus.complete) - .map((e) => e.key); - - /// Returns the number of failed downloads in this batch - int get numFailed => results.values.length - numSucceeded; -} - -/// Base class for updates related to [task]. Actual updates are -/// either a status update or a progress update. -/// -/// When receiving an update, test if the update is a -/// [TaskStatusUpdate] or a [TaskProgressUpdate] -/// and treat the update accordingly -sealed class TaskUpdate { - final Task task; - - const TaskUpdate(this.task); - - /// Create object from [json] - TaskUpdate.fromJson(Map json) - : task = Task.createFromJson(json['task'] ?? json); - - /// Return JSON Map representing object - Map toJson() => {'task': task.toJson()}; -} - -/// A status update -/// -/// Contains [TaskStatus] and, if [TaskStatus.failed] possibly a -/// [TaskException] and if this is a final state possibly [responseBody], -/// [responseHeaders], [responseStatusCode], [mimeType] and [charSet]. -/// Note: header names in [responseHeaders] are converted to lowercase -class TaskStatusUpdate extends TaskUpdate { - final TaskStatus status; // note: serialized as 'taskStatus' - final TaskException? exception; - final String? responseBody; - final int? responseStatusCode; - final Map? responseHeaders; - final String? mimeType; // derived from Content-Type header - final String? charSet; // derived from Content-Type header - - const TaskStatusUpdate(super.task, this.status, - [this.exception, - this.responseBody, - this.responseHeaders, - this.responseStatusCode, - this.mimeType, - this.charSet]); - - /// Create object from [json] - TaskStatusUpdate.fromJson(super.json) - : status = TaskStatus.values[(json['taskStatus'] as num?)?.toInt() ?? 0], - exception = json['exception'] != null - ? TaskException.fromJson(json['exception']) - : null, - responseBody = json['responseBody'], - responseHeaders = json['responseHeaders'] != null - ? Map.from(json['responseHeaders']) - : null, - responseStatusCode = (json['responseStatusCode'] as num?)?.toInt(), - mimeType = json['mimeType'], - charSet = json['charSet'], - super.fromJson(); - - /// Create object from [jsonString] - factory TaskStatusUpdate.fromJsonString(String jsonString) => - TaskStatusUpdate.fromJson(jsonDecode(jsonString)); - - /// Return JSON Map representing object - @override - Map toJson() => { - ...super.toJson(), - 'taskStatus': status.index, - 'exception': exception?.toJson(), - 'responseBody': responseBody, - 'responseHeaders': responseHeaders, - 'responseStatusCode': responseStatusCode, - 'mimeType': mimeType, - 'charSet': charSet - }; - - TaskStatusUpdate copyWith( - {Task? task, - TaskStatus? status, - TaskException? exception, - String? responseBody, - Map? responseHeaders, - int? responseStatusCode, - String? mimeType, - String? charSet}) => - TaskStatusUpdate( - task ?? this.task, - status ?? this.status, - exception ?? this.exception, - responseBody ?? this.responseBody, - responseHeaders ?? this.responseHeaders, - responseStatusCode ?? this.responseStatusCode, - mimeType ?? this.mimeType, - charSet ?? this.charSet); -} - -/// A progress update -/// -/// A successfully downloaded task will always finish with progress 1.0 -/// -/// [TaskStatus.failed] results in progress -1.0 -/// [TaskStatus.canceled] results in progress -2.0 -/// [TaskStatus.notFound] results in progress -3.0 -/// [TaskStatus.waitingToRetry] results in progress -4.0 -/// -/// [expectedFileSize] will only be representative if the 0 < [progress] < 1, -/// so NOT representative when progress == 0 or progress == 1, and -/// will be -1 if the file size is not provided by the server or otherwise -/// not known. -/// [networkSpeed] is valid if positive, expressed in MB/second -/// [timeRemaining] is valid if positive -/// -/// Use the [has...] getters to determine whether a field is valid -class TaskProgressUpdate extends TaskUpdate { - final double progress; - final int expectedFileSize; - final double networkSpeed; // in MB/s - final Duration timeRemaining; - - const TaskProgressUpdate(super.task, this.progress, - [this.expectedFileSize = -1, - this.networkSpeed = -1, - this.timeRemaining = const Duration(seconds: -1)]); - - /// Create object from [json] - TaskProgressUpdate.fromJson(super.json) - : progress = (json['progress'] as num?)?.toDouble() ?? progressFailed, - expectedFileSize = (json['expectedFileSize'] as num?)?.toInt() ?? -1, - networkSpeed = (json['networkSpeed'] as num?)?.toDouble() ?? -1, - timeRemaining = - Duration(seconds: (json['timeRemaining'] as num?)?.toInt() ?? -1), - super.fromJson(); - - /// Create object from [jsonString] - factory TaskProgressUpdate.fromJsonString(String jsonString) => - TaskProgressUpdate.fromJson(jsonDecode(jsonString)); - - /// Return JSON Map representing object - @override - Map toJson() => { - ...super.toJson(), - 'progress': progress, - 'expectedFileSize': expectedFileSize, - 'networkSpeed': networkSpeed, - 'timeRemaining': timeRemaining.inSeconds - }; - - /// If true, [expectedFileSize] contains a valid value - bool get hasExpectedFileSize => expectedFileSize >= 0; - - /// If true, [networkSpeed] contains a valid value - bool get hasNetworkSpeed => networkSpeed >= 0; - - /// If true, [timeRemaining] contains a valid value - bool get hasTimeRemaining => !timeRemaining.isNegative; - - /// String is '-- MB/s' if N/A, otherwise in MB/s or kB/s - String get networkSpeedAsString => switch (networkSpeed) { - <= 0 => '-- MB/s', - >= 1 => '${networkSpeed.round()} MB/s', - _ => '${(networkSpeed * 1000).round()} kB/s' - }; - - /// String is '--:--' if N/A, otherwise HH:MM:SS or MM:SS - String get timeRemainingAsString => switch (timeRemaining.inSeconds) { - <= 0 => '--:--', - < 3600 => '${timeRemaining.inMinutes.toString().padLeft(2, "0")}' - ':${timeRemaining.inSeconds.remainder(60).toString().padLeft(2, "0")}', - _ => '${timeRemaining.inHours}' - ':${timeRemaining.inMinutes.remainder(60).toString().padLeft(2, "0")}' - ':${timeRemaining.inSeconds.remainder(60).toString().padLeft(2, "0")}' - }; - - @override - String toString() { - return 'TaskProgressUpdate{progress: $progress, expectedFileSize: $expectedFileSize, networkSpeed: $networkSpeed, timeRemaining: $timeRemaining}'; - } -} - -// Progress values representing a status -const progressRunning = 0.0; -const progressComplete = 1.0; -const progressFailed = -1.0; -const progressCanceled = -2.0; -const progressNotFound = -3.0; -const progressWaitingToRetry = -4.0; -const progressPaused = -5.0; - -/// Holds data associated with a resume -class ResumeData { - final Task task; - final String data; - final int requiredStartByte; - final String? eTag; - - const ResumeData(this.task, this.data, - [this.requiredStartByte = 0, this.eTag]); - - /// Create object from [json] - ResumeData.fromJson(Map json) - : task = Task.createFromJson(json['task']), - data = json['data'] as String, - requiredStartByte = (json['requiredStartByte'] as num?)?.toInt() ?? 0, - eTag = json['eTag'] as String?; - - /// Create object from [jsonString] - factory ResumeData.fromJsonString(String jsonString) => - ResumeData.fromJson(jsonDecode(jsonString)); - - /// Return JSON Map representing object - Map toJson() => { - 'task': task.toJson(), - 'data': data, - 'requiredStartByte': requiredStartByte, - 'eTag': eTag - }; - - String get taskId => task.taskId; - - /// The tempFilepath contained in the [data] field - String get tempFilepath => data; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is ResumeData && - runtimeType == other.runtimeType && - task == other.task && - data == other.data && - requiredStartByte == other.requiredStartByte && - eTag == other.eTag; - - @override - int get hashCode => - task.hashCode ^ - data.hashCode ^ - requiredStartByte.hashCode ^ - (eTag?.hashCode ?? 0); -} - -/// Types of undelivered data that can be requested -enum Undelivered { resumeData, statusUpdates, progressUpdates } - -/// Notification types, as configured in [TaskNotificationConfig] and passed -/// on to [TaskNotificationTapCallback] -enum NotificationType { running, complete, error, paused } - -/// Notification specification for a [Task] -/// -/// [body] and [title] may contain special strings to substitute display values: -/// {filename] to insert the filename -/// {progress} to insert progress in % -/// {networkSpeed} to insert the network speed in MB/s or kB/s, or '--' if N/A -/// {timeRemaining} to insert the estimated time remaining to complete the task -/// in HH:MM:SS or MM:SS or --:-- if N/A -/// -/// Actual appearance of notification is dependent on the platform, e.g. -/// on iOS {progress} is not available and ignored -final class TaskNotification { - final String title; - final String body; - - const TaskNotification(this.title, this.body); - - /// Return JSON Map representing object - Map toJson() => {"title": title, "body": body}; -} - -/// Notification configuration object -/// -/// Determines how a [taskOrGroup] or [group] of tasks needs to be notified -/// -/// [running] is the notification used while the task is in progress -/// [complete] is the notification used when the task completed -/// [error] is the notification used when something went wrong, -/// including pause, failed and notFound status -/// [progressBar] if set will show a progress bar -/// [tapOpensFile] if set will attempt to open the file when the [complete] -/// notification is tapped -/// [groupNotificationId] if set will group all notifications with the same -/// [groupNotificationId] and change the progress bar to number of finished -/// tasks versus total number of tasks in the groupNotification. -/// Use {finished} and {total} tokens in the [TaskNotification.title] and -/// [TaskNotification.body] to substitute. Task-specific substitutions -/// such as {filename} are not valid. -/// The groupNotification is considered [complete] when there are no -/// more tasks running within that group, and at that point the -/// [complete] notification is shown (if configured). If any task in the -/// groupNotification fails, the [error] notification is shown. -/// The first character of the [groupNotificationId] cannot be '*'. -final class TaskNotificationConfig { - final dynamic taskOrGroup; - final TaskNotification? running; - final TaskNotification? complete; - final TaskNotification? error; - final TaskNotification? paused; - final bool progressBar; - final bool tapOpensFile; - final String groupNotificationId; - - /// Create notification configuration that determines what notifications are shown, - /// whether a progress bar is shown (Android only), and whether tapping - /// the 'complete' notification opens the downloaded file. - /// - /// [running] is the notification used while the task is in progress - /// [complete] is the notification used when the task completed - /// [error] is the notification used when something went wrong, - /// including pause, failed and notFound status - /// [progressBar] if set will show a progress bar - /// [tapOpensFile] if set will attempt to open the file when the [complete] - /// notification is tapped - /// [groupNotificationId] if set will group all notifications with the same - /// [groupNotificationId] and change the progress bar to number of finished - /// tasks versus total number of tasks in the groupNotification. - /// Use {numFinished}, {numFailed} and {numTotal} tokens in the [TaskNotification.title] - /// and [TaskNotification.body] to substitute. Task-specific substitutions - /// such as {filename} are not valid. - /// The groupNotification is considered [complete] when there are no - /// more tasks running within that group, and at that point the - /// [complete] notification is shown (if configured). If any task in the - /// groupNotification fails, the [error] notification is shown. - /// The first character of the [groupNotificationId] cannot be '*'. - TaskNotificationConfig( - {this.taskOrGroup, - this.running, - this.complete, - this.error, - this.paused, - this.progressBar = false, - this.tapOpensFile = false, - this.groupNotificationId = ''}) { - assert( - running != null || complete != null || error != null || paused != null, - 'At least one notification must be set'); - } - - /// Return JSON Map representing object, excluding the [taskOrGroup] field, - /// as the JSON map is only required to pass along the config with a task - Map toJson() => { - 'running': running?.toJson(), - 'complete': complete?.toJson(), - 'error': error?.toJson(), - 'paused': paused?.toJson(), - 'progressBar': progressBar, - 'tapOpensFile': tapOpensFile, - 'groupNotificationId': groupNotificationId - }; -} - -/// Shared storage destinations -enum SharedStorage { - /// The 'Downloads' directory - downloads, - - /// The 'Photos' or 'Images' or 'Pictures' directory - images, - - /// The 'Videos' or 'Movies' directory - video, - - /// The 'Music' or 'Audio' directory - audio, - - /// Android-only: the 'Files' directory - files, - - /// Android-only: the 'external storage' directory - external -} - -final class Config { - // Config topics - static const requestTimeout = 'requestTimeout'; - static const resourceTimeout = 'resourceTimeout'; - static const checkAvailableSpace = 'checkAvailableSpace'; - static const proxy = 'proxy'; - static const bypassTLSCertificateValidation = - 'bypassTLSCertificateValidation'; - static const runInForeground = 'runInForeground'; - static const runInForegroundIfFileLargerThan = - 'runInForegroundIfFileLargerThan'; - static const localize = 'localize'; - static const useCacheDir = 'useCacheDir'; - static const useExternalStorage = 'useExternalStorage'; - static const holdingQueue = 'holdingQueue'; - - // Config arguments - static const always = 'always'; // int 0 on native side - static const never = 'never'; // int -1 on native side - static const whenAble = 'whenAble'; // int -2 on native side - - /// Returns the int equivalent of commonly used String arguments - /// - /// The int equivalent is used in communication with the native downloader - static int argToInt(String argument) { - final value = - {Config.always: 0, Config.whenAble: -2, Config.never: -1}[argument]; - if (value == null) { - throw ArgumentError('Argument $argument cannot be converted to int'); - } - return value; - } -} - -/// Wifi requirement modes at the application level -enum RequireWiFi { asSetByTask, forAllTasks, forNoTasks } diff --git a/lib/services/background_downloader/src/persistent_storage.dart b/lib/services/background_downloader/src/persistent_storage.dart deleted file mode 100644 index c41d07e..0000000 --- a/lib/services/background_downloader/src/persistent_storage.dart +++ /dev/null @@ -1,394 +0,0 @@ -// ignore_for_file: depend_on_referenced_packages - -import 'dart:async'; -import 'dart:io'; - -import 'package:mangayomi/services/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'; -import 'task.dart'; - -/// Interface for the persistent storage used to back the downloader -/// -/// Defines 'store', 'retrieve', 'retrieveAll' and 'remove' methods for: -/// - [TaskRecord]s, keyed by taskId -/// - paused [Task]s, keyed by taskId -/// - [ResumeData], keyed by taskId -/// -/// Each of the objects has a toJson method and can be created using -/// fromJson (use .createFromJson for [Task] objects) -/// -/// Also defined methods to allow migration from one database version to another -abstract interface class PersistentStorage { - /// Store a [TaskRecord], keyed by taskId - Future storeTaskRecord(TaskRecord record); - - /// Retrieve [TaskRecord] with [taskId], or null if not found - Future retrieveTaskRecord(String taskId); - - /// Retrieve all [TaskRecord] - Future> retrieveAllTaskRecords(); - - /// Remove [TaskRecord] with [taskId] from storage. If null, remove all - Future removeTaskRecord(String? taskId); - - /// Store a paused [task], keyed by taskId - Future storePausedTask(Task task); - - /// Retrieve paused [Task] with [taskId], or null if not found - Future retrievePausedTask(String taskId); - - /// Retrieve all paused [Task] - Future> retrieveAllPausedTasks(); - - /// Remove paused [Task] with [taskId] from storage. If null, remove all - Future removePausedTask(String? taskId); - - /// Store [ResumeData], keyed by its taskId - Future storeResumeData(ResumeData resumeData); - - /// Retrieve [ResumeData] with [taskId], or null if not found - Future retrieveResumeData(String taskId); - - /// Retrieve all [ResumeData] - Future> retrieveAllResumeData(); - - /// Remove [ResumeData] with [taskId] from storage. If null, remove all - Future 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 initialize(); -} - -/// 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 metaDataCollection = 'backgroundDownloaderDatabase'; - - /// Stores [Map] formatted [document] in [collection] keyed under [identifier] - Future store(Map document, String collection, - String identifier) async { - await _db.collection(collection).doc(identifier).set(document); - } - - /// Returns [document] stored in [collection] under key [identifier] - /// as a [Map], or null if not found - Future?> retrieve( - String collection, String identifier) => - _db.collection(collection).doc(identifier).get(); - - /// Returns all documents in collection as a [Map] keyed by the - /// document identifier, with the value a [Map] representing the document - Future> 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 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 removePausedTask(String? taskId) => - remove(pausedTasksPath, _safeIdOrNull(taskId)); - - @override - Future removeResumeData(String? taskId) => - remove(resumeDataPath, _safeIdOrNull(taskId)); - - @override - Future removeTaskRecord(String? taskId) => - remove(taskRecordsPath, _safeIdOrNull(taskId)); - - @override - Future> retrieveAllPausedTasks() async { - final jsonMaps = await retrieveAll(pausedTasksPath); - return jsonMaps.values - .map((e) => Task.createFromJson(e)) - .toList(growable: false); - } - - @override - Future> retrieveAllResumeData() async { - final jsonMaps = await retrieveAll(resumeDataPath); - return jsonMaps.values - .map((e) => ResumeData.fromJson(e)) - .toList(growable: false); - } - - @override - Future> retrieveAllTaskRecords() async { - final jsonMaps = await retrieveAll(taskRecordsPath); - return jsonMaps.values - .map((e) => TaskRecord.fromJson(e)) - .toList(growable: false); - } - - @override - Future retrievePausedTask(String taskId) async { - return switch (await retrieve(pausedTasksPath, _safeId(taskId))) { - var json? => Task.createFromJson(json), - _ => null - }; - } - - @override - Future retrieveResumeData(String taskId) async { - return switch (await retrieve(resumeDataPath, _safeId(taskId))) { - var json? => ResumeData.fromJson(json), - _ => null - }; - } - - @override - Future retrieveTaskRecord(String taskId) async { - return switch (await retrieve(taskRecordsPath, _safeId(taskId))) { - var json? => TaskRecord.fromJson(json), - _ => null - }; - } - - @override - Future storePausedTask(Task task) => - store(task.toJson(), pausedTasksPath, _safeId(task.taskId)); - - @override - Future storeResumeData(ResumeData resumeData) => - store(resumeData.toJson(), resumeDataPath, _safeId(resumeData.taskId)); - - @override - Future storeTaskRecord(TaskRecord record) => - store(record.toJson(), 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 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, - 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}); - } -} - -/// Interface to migrate from one persistent storage to another -abstract interface class PersistentStorageMigrator { - /// Migrate data from one of the [migrationOptions] to the [toStorage] - /// - /// If migration took place, returns the name of the migration option, - /// otherwise returns null - Future migrate( - List migrationOptions, PersistentStorage toStorage); -} - -/// Migrates from [LocalStorePersistentStorage] to another [PersistentStorage] -class BasePersistentStorageMigrator implements PersistentStorageMigrator { - final log = Logger('PersistentStorageMigrator'); - - /// Create [BasePersistentStorageMigrator] object to migrate between persistent - /// storage solutions - /// - /// [BasePersistentStorageMigrator] only migrates from: - /// * local_store (the default implementation of the database in - /// background_downloader). - /// - /// To add other migrations, extend this class and inject it in the - /// [PersistentStorage] class that you want to migrate to. - /// - /// See package background_downloader_sql for an implementation - /// that migrates to a SQLite based [PersistentStorage], including - /// migration from Flutter Downloader - BasePersistentStorageMigrator(); - - /// Migrate data from one of the [migrationOptions] to the [toStorage] - /// - /// If migration took place, returns the name of the migration option, - /// otherwise returns null - /// - /// This is the public interface to use in other [PersistentStorage] - /// solutions. - @override - Future migrate( - List 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 migrateFrom( - String persistentStorageName, PersistentStorage toStorage) => - switch (persistentStorageName.toLowerCase().replaceAll('_', '')) { - 'localstore' => migrateFromLocalStore(toStorage), - _ => Future.value(false) - }; - - /// Migrate from a persistent storage to our database - /// - /// Returns true if this migration took place - /// - /// This is a generic migrator that copies from one storage to another, and - /// is used by the _migrateFrom... methods - Future 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 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 - Future 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.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 - } -} diff --git a/lib/services/background_downloader/src/queue/task_queue.dart b/lib/services/background_downloader/src/queue/task_queue.dart deleted file mode 100644 index 809f51d..0000000 --- a/lib/services/background_downloader/src/queue/task_queue.dart +++ /dev/null @@ -1,11 +0,0 @@ -// ignore_for_file: depend_on_referenced_packages - -import '../file_downloader.dart'; -import '../task.dart'; - -/// Interface allowing the [FileDownloader] to signal finished tasks to -/// a [TaskQueue] -abstract interface class TaskQueue { - /// Signals that [task] has finished - void taskFinished(Task task); -} diff --git a/lib/services/background_downloader/src/task.dart b/lib/services/background_downloader/src/task.dart deleted file mode 100644 index 7c2b252..0000000 --- a/lib/services/background_downloader/src/task.dart +++ /dev/null @@ -1,1336 +0,0 @@ -// ignore_for_file: depend_on_referenced_packages - -import 'dart:collection'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:math' show Random; -import 'dart:typed_data'; - -import 'package:http/http.dart' as http; -import 'package:logging/logging.dart'; -import 'package:mime/mime.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; -import 'file_downloader.dart'; -import 'models.dart'; -import 'utils.dart'; - -final _log = Logger('FileDownloader'); - -/// A server Request -/// -/// An equality test on a [Request] is an equality test on the [url] -base class Request { - final validHttpMethods = ['GET', 'POST', 'HEAD', 'PUT', 'DELETE', 'PATCH']; - - /// String representation of the url, urlEncoded - final String url; - - /// potential additional headers to send with the request - final Map headers; - - /// HTTP request method to use - final String httpRequestMethod; - - /// Set [post] to make the request using POST instead of GET. - /// In the constructor, [post] must be one of the following: - /// - a String: POST request with [post] as the body, encoded in utf8 - /// - a Map: will be jsonEncoded to a String and set as the POST body - /// - a List of bytes: will be converted to a String using String.fromCharCodes - /// and set as the POST body - /// - a List: map will be jsonEncoded to a String and set as the POST body - /// - /// The field [post] will be a String - final String? post; - - /// Maximum number of retries the downloader should attempt - /// - /// Defaults to 0, meaning no retry will be attempted - final int retries; - - /// Number of retries remaining - int retriesRemaining; - - /// Time at which this request was first created - final DateTime creationTime; - - /// Creates a [Request] - /// - /// [url] must not be encoded and can include query parameters - /// [urlQueryParameters] may be added and will be appended to the [url] - /// [headers] an optional map of HTTP request headers - /// [post] if set, uses POST instead of GET. Post must be one of the - /// following: - /// - a String: POST request with [post] as the body, encoded in utf8 - /// - a Map: will be jsonEncoded to a String and set as the POST body - /// - a List of bytes: will be converted to a String using String.fromCharCodes - /// and set as the POST body - /// - a List: map will be jsonEncoded to a String and set as the POST body - /// - /// [retries] if >0 will retry a failed download this many times - Request( - {required String url, - Map? urlQueryParameters, - Map? headers, - String? httpRequestMethod, - post, - this.retries = 0, - DateTime? creationTime}) - : url = urlWithQueryParameters(url, urlQueryParameters), - headers = headers ?? {}, - httpRequestMethod = - httpRequestMethod?.toUpperCase() ?? (post == null ? 'GET' : 'POST'), - post = post is Uint8List - ? String.fromCharCodes(post) - : post is Map || post is List - ? jsonEncode(post) - : post, - retriesRemaining = retries, - creationTime = creationTime ?? DateTime.now() { - if (retries < 0 || retries > 10) { - throw ArgumentError('Number of retries must be in range 1 through 10'); - } - if (!validHttpMethods.contains(this.httpRequestMethod)) { - throw ArgumentError( - 'Invalid httpRequestMethod "${this.httpRequestMethod}": Must be one of ${validHttpMethods.join(', ')}'); - } - } - - /// Creates object from [json] - Request.fromJson(Map json) - : url = json['url'] ?? '', - headers = Map.from(json['headers'] ?? {}), - httpRequestMethod = json['httpRequestMethod'] as String? ?? - (json['post'] == null ? 'GET' : 'POST'), - post = json['post'] as String?, - retries = (json['retries'] as num?)?.toInt() ?? 0, - retriesRemaining = (json['retriesRemaining'] as num?)?.toInt() ?? 0, - creationTime = DateTime.fromMillisecondsSinceEpoch( - (json['creationTime'] as num?)?.toInt() ?? 0); - - /// Creates JSON map of this object - Map toJson() => { - 'url': url, - 'headers': headers, - 'httpRequestMethod': httpRequestMethod, - 'post': post, - 'retries': retries, - 'retriesRemaining': retriesRemaining, - 'creationTime': creationTime.millisecondsSinceEpoch - }; - - /// The regex pattern to split the cookies in `Set-Cookie`. - static final _regexSplitSetCookies = RegExp(',(?=[^ ])'); - - /// Returns the cookie header appropriate for this [request], - /// taken from the [cookies] list. - /// - /// [cookies] can be a List or the 'Set-Cookie' header value - /// - /// The returned map is the 'Cookie:' header, with the - /// value=name; value2=name2 as the value. - static Map cookieHeader(dynamic cookies, String url) { - final Uri uri; - try { - uri = Uri.parse(url); - } catch (e) { - _log.fine('Invalid url: $url error: $e'); - return {}; - } - final List cookieList = switch (cookies) { - http.Response response => - cookiesFromSetCookie(response.headers['set-cookie'] ?? ''), - List list => list, - String _ => cookiesFromSetCookie(cookies), - _ => throw ArgumentError( - 'cookies parameter must be a http.Response object, a String or a List') - }; - final path = uri.path.isNotEmpty ? uri.path : '/'; - final validCookies = cookieList.where((cookie) => - (cookie.maxAge == null || cookie.maxAge! > 0) && - (cookie.domain == null || - uri.host.endsWith(cookie.domain!) || - (cookie.domain!.startsWith('.') && - uri.host == cookie.domain!.substring(1))) && - (cookie.path == null || path.startsWith(cookie.path!)) && - (cookie.expires == null || cookie.expires!.isAfter(DateTime.now())) && - (!cookie.secure || uri.scheme == 'https')); - final cookieHeaderValue = validCookies - .map((c) => c.name.isNotEmpty ? '${c.name}=${c.value}' : c.value) - .join('; '); - return cookieHeaderValue.isNotEmpty ? {'Cookie': cookieHeaderValue} : {}; - } - - /// Returns a list of Cookies extracted from the [setCookie] string, - /// which is the value of the 'Set-Cookie' header of a server response - /// - /// Based on https://github.com/dart-lang/http/pull/688/files - static List cookiesFromSetCookie(String setCookie) { - final cookies = []; - if (setCookie.isNotEmpty) { - for (final cookie in setCookie.split(_regexSplitSetCookies)) { - cookies.add(Cookie.fromSetCookieValue(cookie)); - } - } - return cookies; - } - - /// Decrease [retriesRemaining] by one - void decreaseRetriesRemaining() => retriesRemaining--; - - /// Hostname represented by the url. Throws [FormatException] if url cannot - /// be parsed, and returns empty string if no host in url - String get hostName => Uri.parse(url).host; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is Request && runtimeType == other.runtimeType && url == other.url; - - @override - int get hashCode => url.hashCode; - - @override - String toString() { - return 'Request{url: $url, headers: $headers, httpRequestMethod: ' - '$httpRequestMethod, post: ${post == null ? "null" : "not null"}, ' - 'retries: $retries, retriesRemaining: $retriesRemaining}'; - } -} - -/// RegEx to match a path separator -final _pathSeparator = RegExp(r'[/\\]'); -final _startsWithPathSeparator = RegExp(r'^[/\\]'); - -/// Information related to a [Task] -/// -/// A [Task] is the base class for [DownloadTask] and -/// [UploadTask] -/// -/// An equality test on a [Task] is a test on the [taskId] -/// only - all other fields are ignored in that test -sealed class Task extends Request implements Comparable { - /// Identifier for the task - auto generated if omitted - final String taskId; - - /// Filename of the file to store - use {filename} in notification - final String filename; - - /// Optional directory, relative to the base directory - final String directory; - - /// Base directory - final BaseDirectory baseDirectory; - - /// Group that this task belongs to - final String group; - - /// Type of progress updates desired - final Updates updates; - - /// If true, will not download over cellular (metered) network - final bool requiresWiFi; - - /// If true, task will pause if the task fails partly through the execution, - /// when some but not all bytes have transferred, provided the server supports - /// partial transfers. Such failures are typically temporary, eg due to - /// connectivity issues, and may be resumed when connectivity returns. - /// If false, task fails on any issue, and task cannot be paused - final bool allowPause; - - /// Priority of this task, relative to other tasks. - /// Range 0 <= priority <= 10 with 0 being the highest priority. - /// Not all platforms will have the same actual granularity, and how - /// priority is considered is inconsistent across platforms. - final int priority; - - /// User-defined metadata - use {metaData} in notification - final String metaData; - - /// Human readable name for this task - use {displayName} in notification - final String displayName; - - static bool useExternalStorage = false; // for Android configuration only - - /// Creates a [Task] - /// - /// [taskId] must be unique. A unique id will be generated if omitted - /// [url] properly encoded if necessary, can include query parameters - /// [urlQueryParameters] may be added and will be appended to the [url], must - /// be properly encoded if necessary - /// [filename] of the file to save. If omitted, a random filename will be - /// generated - /// [headers] an optional map of HTTP request headers - /// [httpRequestMethod] the HTTP request method used (e.g. GET, POST) - /// [post] if set, uses POST instead of GET. Post must be one of the - /// following: - /// - a String: POST request with [post] as the body, encoded in utf8 - /// - a Map: will be jsonEncoded to a String and set as the POST body - /// - a List of bytes: will be converted to a String using String.fromCharCodes - /// and set as the POST body - /// - a List: map will be jsonEncoded to a String and set as the POST body - /// [directory] optional directory name, precedes [filename] - /// [baseDirectory] one of the base directories, precedes [directory] - /// [group] if set allows different callbacks or processing for different - /// groups - /// [updates] the kind of progress updates requested - /// [requiresWiFi] if set, will not start download until WiFi is available. - /// If not set may start download over cellular network - /// [retries] if >0 will retry a failed download this many times - /// [allowPause] - /// If true, task will pause if the task fails partly through the execution, - /// when some but not all bytes have transferred, provided the server supports - /// partial transfers. Such failures are typically temporary, eg due to - /// connectivity issues, and may be resumed when connectivity returns - /// [priority] in range 0 <= priority <= 10 with 0 highest, defaults to 5 - /// [metaData] user data - /// [displayName] human readable name for this task - /// [creationTime] time of task creation, 'now' by default. - Task( - {String? taskId, - required super.url, - super.urlQueryParameters, - String? filename, - super.headers, - super.httpRequestMethod, - super.post, - String directory = '', - this.baseDirectory = BaseDirectory.applicationDocuments, - this.group = 'default', - this.updates = Updates.status, - this.requiresWiFi = false, - super.retries, - this.metaData = '', - this.displayName = '', - this.allowPause = false, - this.priority = 5, - super.creationTime}) - : taskId = taskId ?? Random().nextInt(1 << 32).toString(), - filename = filename ?? Random().nextInt(1 << 32).toString(), - directory = _startsWithPathSeparator.hasMatch(directory) - ? directory.substring(1) - : directory { - if (filename?.isEmpty == true) { - throw ArgumentError('Filename cannot be empty'); - } - if (_pathSeparator.hasMatch(this.filename) && this is! MultiUploadTask) { - throw ArgumentError('Filename cannot contain path separators'); - } - if (allowPause && post != null) { - throw ArgumentError('Tasks that can pause must be GET requests'); - } - if (priority < 0 || priority > 10) { - throw ArgumentError('Priority must be 0 <= priority <= 10'); - } - } - - /// Create a new [Task] subclass from the provided [json] - factory Task.createFromJson(Map json) => - switch (json['taskType']) { - 'DownloadTask' => DownloadTask.fromJson(json), - 'UploadTask' => UploadTask.fromJson(json), - 'MultiUploadTask' => MultiUploadTask.fromJson(json), - 'ParallelDownloadTask' => ParallelDownloadTask.fromJson(json), - 'DataTask' => DataTask.fromJson(json), - _ => throw ArgumentError( - 'taskType not in [DownloadTask, UploadTask, MultiUploadTask, ParallelDownloadTask, DataTask]') - }; - - /// Create a new [Task] subclass from provided [jsonString] - factory Task.createFromJsonString(String jsonString) { - return Task.createFromJson(jsonDecode(jsonString)); - } - - /// Returns the absolute path to the file represented by this task - /// based on the [Task.filename] (default) or [withFilename] - /// - /// If the task is a MultiUploadTask and no [withFilename] is given, - /// returns the empty string, as there is no single path that can be - /// returned. - /// - /// Throws a FileSystemException if using external storage on Android (via - /// configuration at startup), and external storage is not available. - Future filePath({String? withFilename}) async { - if (this is MultiUploadTask && withFilename == null) { - return ''; - } - final baseDirPath = await baseDirectoryPath(baseDirectory); - return p.join(baseDirPath, directory, withFilename ?? filename); - } - - /// Returns the path to the directory represented by [baseDirectory] - /// - /// On Windows, if [baseDirectory] is .root, returns the empty string - /// because the drive letter is required to be included in the directory - /// path - static Future baseDirectoryPath(BaseDirectory baseDirectory) async { - Directory? externalStorageDirectory; - Directory? externalCacheDirectory; - if (Task.useExternalStorage) { - externalStorageDirectory = await getExternalStorageDirectory(); - externalCacheDirectory = (await getExternalCacheDirectories())?.first; - if (externalStorageDirectory == null || externalCacheDirectory == null) { - throw const FileSystemException( - 'Android external storage is not available'); - } - } - final baseDir = switch ((baseDirectory, Task.useExternalStorage)) { - (BaseDirectory.applicationDocuments, false) => - await getApplicationDocumentsDirectory(), - (BaseDirectory.temporary, false) => await getTemporaryDirectory(), - (BaseDirectory.applicationSupport, false) => - await getApplicationSupportDirectory(), - (BaseDirectory.applicationLibrary, false) - when Platform.isMacOS || Platform.isIOS => - await getLibraryDirectory(), - (BaseDirectory.applicationLibrary, false) => Directory( - p.join((await getApplicationSupportDirectory()).path, 'Library')), - (BaseDirectory.root, _) => Directory('/'), - // Android only: external storage variants - (BaseDirectory.applicationDocuments, true) => externalStorageDirectory!, - (BaseDirectory.temporary, true) => externalCacheDirectory!, - (BaseDirectory.applicationSupport, true) => - Directory(p.join(externalStorageDirectory!.path, 'Support')), - (BaseDirectory.applicationLibrary, true) => - Directory(p.join(externalStorageDirectory!.path, 'Library')) - }; - return (Platform.isWindows && baseDirectory == BaseDirectory.root) - ? '' - : baseDir.absolute.path; - } - - /// Extract the baseDirectory, directory and filename from - /// the provided [filePath] or [file], and return this as a record - /// - /// Either [filePath] or [file] must be provided, not both. - /// - /// Throws a FileSystemException if using external storage on Android (via - /// configuration at startup), and external storage is not available. - static Future< - (BaseDirectory baseDirectory, String directory, String filename)> - split({String? filePath, File? file}) async { - assert((filePath != null) ^ (file != null), - 'Either filePath or file must be given and not both'); - final path = filePath ?? file!.absolute.path; - final absoluteDirectoryPath = p.dirname(path); - final filename = p.basename(path); - // try to match the start of the absoluteDirectory to one of the - // directories represented by the BaseDirectory enum. - // Order matters, as some may be subdirs of others - final testSequence = - Platform.isAndroid || Platform.isLinux || Platform.isWindows - ? [ - BaseDirectory.temporary, - BaseDirectory.applicationLibrary, - BaseDirectory.applicationSupport, - BaseDirectory.applicationDocuments - ] - : [ - BaseDirectory.temporary, - BaseDirectory.applicationSupport, - BaseDirectory.applicationLibrary, - BaseDirectory.applicationDocuments - ]; - for (final baseDirectoryEnum in testSequence) { - final baseDirPath = await baseDirectoryPath(baseDirectoryEnum); - final (match, directory) = _contains(baseDirPath, absoluteDirectoryPath); - if (match) { - return (baseDirectoryEnum, directory, filename); - } - } - // if no match, return a BaseDirectory.root with the absoluteDirectory - // minus the leading characters that designate the root (differs by platform) - final match = - RegExp(r'^(/|\\|([a-zA-Z]:[\\/]))').firstMatch(absoluteDirectoryPath); - return ( - BaseDirectory.root, - absoluteDirectoryPath.substring(match?.end ?? 0), - filename - ); - } - - /// Returns the subdirectory of the given [baseDirPath] within [dirPath], - /// if [dirPath] starts with [baseDirPath]. - /// - /// If found, returns (true, subdir) otherwise returns (false, ''). - /// - /// [dirPath] should not contain a filename - if it does, it is returned - /// as part of the subdir. - static (bool, String) _contains(String baseDirPath, String dirPath) { - final escapedBaseDirPath = - '$baseDirPath${Platform.pathSeparator}?'.replaceAll(r'\', r'\\'); - final match = RegExp('^$escapedBaseDirPath(.*)').firstMatch(dirPath); - return (match != null, match?.group(1) ?? ''); - } - - /// Returns a copy of the [Task] with optional changes to specific fields - Task copyWith( - {String? taskId, - String? url, - String? filename, - Map? headers, - String? httpRequestMethod, - Object? post, - String? directory, - BaseDirectory? baseDirectory, - String? group, - Updates? updates, - bool? requiresWiFi, - int? retries, - int? retriesRemaining, - bool? allowPause, - int? priority, - String? metaData, - String? displayName, - DateTime? creationTime}); - - /// Creates [Task] object from JsonMap - /// - /// Only used by subclasses. Use [createFromJsonMap] to create a properly - /// subclassed [Task] from the [json] - Task.fromJson(super.json) - : taskId = json['taskId'] ?? '', - filename = json['filename'] ?? '', - directory = json['directory'] ?? '', - baseDirectory = - BaseDirectory.values[(json['baseDirectory'] as num?)?.toInt() ?? 0], - group = json['group'] ?? FileDownloader.defaultGroup, - updates = Updates.values[(json['updates'] as num?)?.toInt() ?? 0], - requiresWiFi = json['requiresWiFi'] ?? false, - allowPause = json['allowPause'] ?? false, - priority = (json['priority'] as num?)?.toInt() ?? 5, - metaData = json['metaData'] ?? '', - displayName = json['displayName'] ?? '', - super.fromJson(); - - /// Creates JSON map of this object - @override - Map toJson() => { - ...super.toJson(), - 'taskId': taskId, - 'filename': filename, - 'directory': directory, - 'baseDirectory': baseDirectory.index, // stored as int - 'group': group, - 'updates': updates.index, // stored as int - 'requiresWiFi': requiresWiFi, - 'allowPause': allowPause, - 'priority': priority, - 'metaData': metaData, - 'displayName': displayName, - 'taskType': taskType - }; - - /// If true, task expects progress updates - bool get providesProgressUpdates => - updates == Updates.progress || updates == Updates.statusAndProgress; - - /// If true, task expects status updates - bool get providesStatusUpdates => - updates == Updates.status || updates == Updates.statusAndProgress; - - /// Returns the type of task as a String - /// - /// Used to identify the task type in JSON format - String get taskType => 'Task'; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is Task && - runtimeType == other.runtimeType && - taskId == other.taskId; - - @override - int get hashCode => taskId.hashCode; - - /// Returns this.priority - other.priority if not the same - /// Returns this.creationTime - other.creationTime if priorities the same - /// Returns 0 if other is not a [Task] - @override - int compareTo(other) { - if (other is Task) { - final diff = priority - other.priority; - if (diff != 0) { - return diff; - } - return creationTime.difference(other.creationTime).inMicroseconds; - } - return 0; - } - - @override - String toString() { - return '$taskType{taskId: $taskId, url: $url, filename: $filename, headers: ' - '$headers, httpRequestMethod: $httpRequestMethod, post: ${post == null ? "null" : "not null"}, directory: $directory, baseDirectory: $baseDirectory, group: $group, updates: $updates, requiresWiFi: $requiresWiFi, retries: $retries, retriesRemaining: $retriesRemaining, allowPause: $allowPause, priority: $priority, metaData: $metaData, displayName: $displayName}'; - } -} - -/// Information related to a download task -/// -/// An equality test on a [DownloadTask] is a test on the [taskId] -/// only - all other fields are ignored in that test -final class DownloadTask extends Task { - /// Creates a [DownloadTask] - /// - /// [taskId] must be unique. A unique id will be generated if omitted - /// [url] properly encoded if necessary, can include query parameters - /// [urlQueryParameters] may be added and will be appended to the [url], must - /// be properly encoded if necessary - /// [filename] of the file to save. If omitted, a random filename will be - /// generated - /// [headers] an optional map of HTTP request headers - /// [httpRequestMethod] the HTTP request method used (e.g. GET, POST) - /// [post] if set, uses POST instead of GET. Post must be one of the - /// following: - /// - a String: POST request with [post] as the body, encoded in utf8 - /// - a Map: will be jsonEncoded to a String and set as the POST body - /// - a List of bytes: will be converted to a String using String.fromCharCodes - /// and set as the POST body - /// - a List: map will be jsonEncoded to a String and set as the POST body - /// - /// [directory] optional directory name, precedes [filename] - /// [baseDirectory] one of the base directories, precedes [directory] - /// [group] if set allows different callbacks or processing for different - /// groups - /// [updates] the kind of progress updates requested - /// [requiresWiFi] if set, will not start download until WiFi is available. - /// If not set may start download over cellular network - /// [retries] if >0 will retry a failed download this many times - /// [allowPause] if true, allows pause command - /// [priority] in range 0 <= priority <= 10 with 0 highest, defaults to 5 - /// [metaData] user data - /// [displayName] human readable name for this task - /// [creationTime] time of task creation, 'now' by default. - DownloadTask( - {super.taskId, - required super.url, - super.urlQueryParameters, - super.filename, - super.headers, - super.httpRequestMethod, - super.post, - super.directory, - super.baseDirectory, - super.group, - super.updates, - super.requiresWiFi, - super.retries, - super.allowPause, - super.priority, - super.metaData, - super.displayName, - super.creationTime}); - - /// Creates [DownloadTask] object from [json] - DownloadTask.fromJson(super.json) - : assert( - ['DownloadTask', 'ParallelDownloadTask'].contains(json['taskType']), - 'The provided JSON map is not' - ' a DownloadTask, because key "taskType" is not "DownloadTask" or "ParallelDownloadTask".'), - super.fromJson(); - - @override - String get taskType => 'DownloadTask'; - - @override - DownloadTask copyWith( - {String? taskId, - String? url, - String? filename, - Map? headers, - String? httpRequestMethod, - Object? post, - String? directory, - BaseDirectory? baseDirectory, - String? group, - Updates? updates, - bool? requiresWiFi, - int? retries, - int? retriesRemaining, - bool? allowPause, - int? priority, - String? metaData, - String? displayName, - DateTime? creationTime}) => - DownloadTask( - taskId: taskId ?? this.taskId, - url: url ?? this.url, - filename: filename ?? this.filename, - headers: headers ?? this.headers, - httpRequestMethod: httpRequestMethod ?? this.httpRequestMethod, - post: post ?? this.post, - directory: directory ?? this.directory, - baseDirectory: baseDirectory ?? this.baseDirectory, - group: group ?? this.group, - updates: updates ?? this.updates, - requiresWiFi: requiresWiFi ?? this.requiresWiFi, - retries: retries ?? this.retries, - allowPause: allowPause ?? this.allowPause, - priority: priority ?? this.priority, - metaData: metaData ?? this.metaData, - displayName: displayName ?? this.displayName, - creationTime: creationTime ?? this.creationTime) - ..retriesRemaining = retriesRemaining ?? this.retriesRemaining; - - /// Returns a copy of the task with the [Task.filename] property changed - /// to the filename suggested by the server, or derived from the url, or - /// unchanged. - /// - /// If [unique] is true, the filename is guaranteed not to already exist. This - /// is accomplished by adding a suffix to the suggested filename with a number, - /// e.g. "data (2).txt" - /// If a [taskWithFilenameBuilder] is supplied, this is the function called to - /// convert the task, response headers and [unique] values into a new [DownloadTask] - /// with the suggested filename. By default, [taskWithSuggestedFilename] is used, - /// which parses the Content-Disposition according to RFC6266, or takes the last - /// path segment of the URL, or leaves the filename unchanged - /// - /// The suggested filename is obtained by making a HEAD request to the url - /// represented by the [DownloadTask], including urlQueryParameters and headers - - /// Constant used with `filename` field to indicate server suggestion requested - static const suggestedFilename = '?'; - - /// True if this task has a filename, or false if set to `suggest` - bool get hasFilename => filename != suggestedFilename; -} - -/// Information related to an upload task -/// -/// An equality test on a [UploadTask] is a test on the [taskId] -/// only - all other fields are ignored in that test -final class UploadTask extends Task { - /// Name of the field used for multi-part file upload - final String fileField; - - /// mimeType of the file to upload - final String mimeType; - - /// Map of name/value pairs to encode as form fields in a multi-part upload. - /// To specify multiple values for a single name, format the value as - /// '"value1", "value2", "value3"' so that it matches the following - /// RegEx: ^(?:"[^"]+"\s*,\s*)+"[^"]+"$ - final Map fields; - - /// Creates [UploadTask] - /// - /// [taskId] must be unique. A unique id will be generated if omitted - /// [url] properly encoded if necessary, can include query parameters - /// [urlQueryParameters] may be added and will be appended to the [url], must - /// be properly encoded if necessary - /// [filename] of the file to upload - /// [headers] an optional map of HTTP request headers - /// [httpRequestMethod] the HTTP request method used (e.g. GET, POST) - /// [post] if set to 'binary' will upload as binary file, otherwise multi-part - /// [fileField] for multi-part uploads, name of the file field or 'file' by - /// default - /// [mimeType] the mimeType of the file, or derived from filename extension - /// by default - /// [fields] for multi-part uploads, optional map of name/value pairs to upload - /// along with the file as form fields - /// [directory] optional directory name, precedes [filename] - /// [baseDirectory] one of the base directories, precedes [directory] - /// [group] if set allows different callbacks or processing for different - /// groups - /// [updates] the kind of progress updates requested - /// [requiresWiFi] if set, will not start upload until WiFi is available. - /// If not set may start upload over cellular network - /// [priority] in range 0 <= priority <= 10 with 0 highest, defaults to 5 - /// [retries] if >0 will retry a failed upload this many times - /// [metaData] user data - /// [displayName] human readable name for this task - /// [creationTime] time of task creation, 'now' by default. - UploadTask( - {super.taskId, - required super.url, - super.urlQueryParameters, - required String super.filename, - super.headers, - String? httpRequestMethod, - String? super.post, - this.fileField = 'file', - String? mimeType, - Map? fields, - super.directory, - super.baseDirectory, - super.group, - super.updates, - super.requiresWiFi, - super.retries, - super.priority, - super.metaData, - super.displayName, - super.creationTime}) - : assert(filename.isNotEmpty, 'A filename is required'), - assert(post == null || post == 'binary', - 'post field must be null, or "binary" for binary file upload'), - assert(fields == null || fields.isEmpty || post != 'binary', - 'fields only allowed for multi-part uploads'), - fields = fields ?? {}, - mimeType = - mimeType ?? lookupMimeType(filename) ?? 'application/octet-stream', - super( - httpRequestMethod: httpRequestMethod ?? 'POST', allowPause: false); - - /// Creates [UploadTask] from a [File] object, using the [file] absolute path. - /// - /// Note that using absolute paths is discouraged on mobile, as the path to - /// files in an application's directory scope is not stable between application - /// starts. Use the combination of [baseDirectory], [directory] and [filename] - /// whenever possible to prevent hard to debug errors. - UploadTask.fromFile( - {required File file, - super.taskId, - required super.url, - super.urlQueryParameters, - super.headers, - String? httpRequestMethod, - String? super.post, - this.fileField = 'file', - String? mimeType, - Map? fields, - super.group, - super.updates, - super.requiresWiFi, - super.retries, - super.priority, - super.metaData, - super.displayName, - super.creationTime}) - : fields = fields ?? {}, - mimeType = - mimeType ?? lookupMimeType(file.path) ?? 'application/octet-stream', - super( - baseDirectory: BaseDirectory.root, - directory: p.dirname(file.absolute.path), - filename: p.basename(file.absolute.path), - httpRequestMethod: httpRequestMethod ?? 'POST', - allowPause: false); - - /// Creates [UploadTask] object from [json] - UploadTask.fromJson(super.json) - : assert( - ['UploadTask', 'MultiUploadTask'].contains(json['taskType']), - 'The provided JSON map is not an UploadTask, ' - 'because key "taskType" is not "UploadTask" or "MultiUploadTask.'), - fileField = json['fileField'] ?? 'file', - mimeType = json['mimeType'] ?? 'application/octet-stream', - fields = Map.from(json['fields'] ?? {}), - super.fromJson(); - - /// Returns a list of fileData elements, one for each file to upload. - /// Each element is a triple containing fileField, full filePath, mimeType - /// - /// The lists are stored in the similarly named String fields as a JSON list, - /// with each list the same length. For the filenames list, if a filename refers - /// to a file that exists (i.e. it is a full path) then that is the filePath used, - /// otherwise the filename is appended to the [Task.baseDirectory] and [Task.directory] - /// to form a full file path - Future> extractFilesData() async { - final List fileFields = List.from(jsonDecode(fileField)); - final List filenames = List.from(jsonDecode(filename)); - final List mimeTypes = List.from(jsonDecode(mimeType)); - final result = <(String, String, String)>[]; - for (int i = 0; i < fileFields.length; i++) { - final file = File(filenames[i]); - if (await file.exists()) { - result.add((fileFields[i], filenames[i], mimeTypes[i])); - } else { - result.add( - ( - fileFields[i], - await filePath(withFilename: filenames[i]), - mimeTypes[i], - ), - ); - } - } - return result; - } - - @override - Map toJson() => { - ...super.toJson(), - 'fileField': fileField, - 'mimeType': mimeType, - 'fields': fields - }; - - @override - String get taskType => 'UploadTask'; - - @override - UploadTask copyWith( - {String? taskId, - String? url, - String? filename, - Map? headers, - String? httpRequestMethod, - Object? post, - String? fileField, - String? mimeType, - Map? fields, - String? directory, - BaseDirectory? baseDirectory, - String? group, - Updates? updates, - bool? requiresWiFi, - int? retries, - int? retriesRemaining, - bool? allowPause, - int? priority, - String? metaData, - String? displayName, - DateTime? creationTime}) => - UploadTask( - taskId: taskId ?? this.taskId, - url: url ?? this.url, - filename: filename ?? this.filename, - headers: headers ?? this.headers, - httpRequestMethod: httpRequestMethod ?? this.httpRequestMethod, - post: post as String? ?? this.post, - fileField: fileField ?? this.fileField, - mimeType: mimeType ?? this.mimeType, - fields: fields ?? this.fields, - directory: directory ?? this.directory, - baseDirectory: baseDirectory ?? this.baseDirectory, - group: group ?? this.group, - updates: updates ?? this.updates, - requiresWiFi: requiresWiFi ?? this.requiresWiFi, - priority: priority ?? this.priority, - retries: retries ?? this.retries, - metaData: metaData ?? this.metaData, - displayName: displayName ?? this.displayName, - creationTime: creationTime ?? this.creationTime) - ..retriesRemaining = retriesRemaining ?? this.retriesRemaining; - - @override - String toString() => '${super.toString()} and fileField $fileField, ' - 'mimeType $mimeType and fields $fields'; -} - -/// Information related to an UploadTask, containing multiple files to upload -/// -/// An equality test on a [UploadTask] is a test on the [taskId] -/// only - all other fields are ignored in that test -/// -/// A [MultiUploadTask] is initialized with a list representing the files to upload. -/// Each element is either a filename/path, or a (fileField, filename/path), -/// or a (fileField, filename/path, mimeType). -/// When instantiating a [MultiUploadTask], this list is converted into -/// three lists: [fileFields], [filenames], and [mimeTypes], available -/// as fields. These lists are also encoded to a JSON string representation in -/// the fields [fileField], [filename] and [mimeType],so - different from -/// a single [UploadTask] - these fields now contain a JSON object representing all -/// files. -/// filename/path means either a filename without directory (and the -/// directory will be based on the [Task.baseDirectory] and [Task.directory] -/// fields), or you specify a full file path. For example: "hello.txt" or -/// "/data/com.myapp/data/dir/hello.txt" -final class MultiUploadTask extends UploadTask { - final List fileFields, filenames, mimeTypes; - - static const _filesArgumentError = - 'files must be a list of filenames, or a list of records of type ' - '(fileField, filename) or (fileField, filename, mimeType)'; - - /// Creates [UploadTask] - /// - /// [taskId] must be unique. A unique id will be generated if omitted - /// [url] properly encoded if necessary, can include query parameters - /// [urlQueryParameters] may be added and will be appended to the [url], must - /// be properly encoded if necessary - /// [files] list of objects representing each file to upload. The object must - /// be either a String representing the filename/path (and the fileField will - /// be the filename without extension), a Record of type - /// (String fileField, String filename/path) or a Record with a third String - /// for the mimeType (if omitted, mimeType will be derived from the filename - /// extension). - /// Each file must be based in the directory represented by the combination - /// of [baseDirectory] and [directory], unless a full filepath is given - /// instead of only the filename. For example: "hello.txt" or - /// "/data/com.myapp/data/dir/hello.txt" - /// [headers] an optional map of HTTP request headers - /// [httpRequestMethod] the HTTP request method used (e.g. GET, POST) - /// [fields] optional map of name/value pairs to upload - /// along with the file as form fields - /// [directory] optional directory name, precedes [filename] - /// [baseDirectory] one of the base directories, precedes [directory] - /// [group] if set allows different callbacks or processing for different - /// groups - /// [updates] the kind of progress updates requested - /// [requiresWiFi] if set, will not start upload until WiFi is available. - /// If not set may start upload over cellular network - /// [priority] in range 0 <= priority <= 10 with 0 highest, defaults to 5 - /// [retries] if >0 will retry a failed upload this many times - /// [metaData] user data - /// [displayName] human readable name for this task - /// [creationTime] time of task creation, 'now' by default. - MultiUploadTask( - {super.taskId, - required super.url, - super.urlQueryParameters, - required List files, - super.headers, - super.httpRequestMethod, - super.fields = const {}, - super.directory, - super.baseDirectory, - super.group, - super.updates, - super.requiresWiFi, - super.priority, - super.retries, - super.metaData, - super.displayName, - super.creationTime}) - : fileFields = files - .map((e) => switch (e) { - String filename => p.basenameWithoutExtension(filename), - (String fileField, String _, String _) => fileField, - (String fileField, String _) => fileField, - _ => throw ArgumentError(_filesArgumentError) - }) - .toList(growable: false), - filenames = files - .map((e) => switch (e) { - String filename => filename, - (String _, String filename, String _) => filename, - (String _, String filename) => filename, - _ => throw ArgumentError(_filesArgumentError) - }) - .toList(growable: false), - mimeTypes = files - .map((e) => switch (e) { - (String _, String _, String mimeType) => mimeType, - String filename || - (String _, String filename) => - lookupMimeType(filename) ?? 'application/octet-stream', - _ => throw ArgumentError(_filesArgumentError) - }) - .toList(growable: false), - super(filename: 'multi-upload', fileField: '', mimeType: ''); - - /// For [MultiUploadTask], returns jsonEncoded list of [fileFields] - @override - String get fileField => jsonEncode(fileFields); - - /// For [MultiUploadTask], returns jsonEncoded list of [filenames] - @override - String get filename => jsonEncode(filenames); - - /// For [MultiUploadTask], returns jsonEncoded list of [mimeTypes] - @override - String get mimeType => jsonEncode(mimeTypes); - - /// Creates [MultiUploadTask] object from [json] - MultiUploadTask.fromJson(super.json) - : assert( - json['taskType'] == 'MultiUploadTask', - 'The provided JSON map is not' - ' a MultiUploadTask, because key "taskType" is not "MultiUploadTask".'), - fileFields = - List.from(jsonDecode(json['fileField'] as String? ?? '[]')), - filenames = List.from(jsonDecode(json['filename'] as String? ?? '[]')), - mimeTypes = List.from(jsonDecode(json['mimeType'] as String? ?? '[]')), - super.fromJson(); - - @override - MultiUploadTask copyWith( - {String? taskId, - String? url, - String? filename, - Map? headers, - String? httpRequestMethod, - Object? post, - String? fileField, - String? mimeType, - Map? fields, - String? directory, - BaseDirectory? baseDirectory, - String? group, - Updates? updates, - bool? requiresWiFi, - int? priority, - int? retries, - int? retriesRemaining, - bool? allowPause, - String? metaData, - String? displayName, - DateTime? creationTime}) => - MultiUploadTask( - taskId: taskId ?? this.taskId, - url: url ?? this.url, - files: fileFields.indexed.map(_toRecord).toList(), - headers: headers ?? this.headers, - httpRequestMethod: httpRequestMethod ?? this.httpRequestMethod, - fields: fields ?? this.fields, - directory: directory ?? this.directory, - baseDirectory: baseDirectory ?? this.baseDirectory, - group: group ?? this.group, - updates: updates ?? this.updates, - requiresWiFi: requiresWiFi ?? this.requiresWiFi, - priority: priority ?? this.priority, - retries: retries ?? this.retries, - metaData: metaData ?? this.metaData, - displayName: displayName ?? this.displayName, - creationTime: creationTime ?? this.creationTime) - ..retriesRemaining = retriesRemaining ?? this.retriesRemaining; - - /// Zips the fileField, filename and mimeType at an index to - /// a record - (String, String, String) _toRecord((int, String) record) => - (fileFields[record.$1], filenames[record.$1], mimeTypes[record.$1]); - - @override - String get taskType => 'MultiUploadTask'; -} - -final class ParallelDownloadTask extends DownloadTask { - /// List of URLs to download the file from - final List urls; - - /// Number of chunks per URL - final int chunks; - - /// Creates a [ParallelDownloadTask] - /// - /// A [ParallelDownloadTask] is a [DownloadTask] that downloads the file - /// from one or more URLs, and in one or more chunks per URL. The parallel - /// download may speed up download from slow or restrictive servers. - /// - /// [taskId] must be unique. A unique id will be generated if omitted - /// [url] properly encoded if necessary, can include query parameters - /// and can be a list of urls, each providing the same file. The same - /// [urlQueryParameters] and [headers] will be applied to all urls in the list - /// [urlQueryParameters] may be added and will be appended to the [url], must - /// be properly encoded if necessary - /// [filename] of the file to save. If omitted, a random filename will be - /// generated - /// [headers] an optional map of HTTP request headers - /// [httpRequestMethod] the HTTP request method used (e.g. GET) - /// [chunks] the number of chunks to break the download into, i.e. the - /// number of downloads that will happen in parallel - /// [directory] optional directory name, precedes [filename] - /// [baseDirectory] one of the base directories, precedes [directory] - /// [group] if set allows different callbacks or processing for different - /// groups - /// [updates] the kind of progress updates requested - /// [requiresWiFi] if set, will not start download until WiFi is available. - /// If not set may start download over cellular network - /// [retries] if >0 will retry a failed download this many times - /// [allowPause] if true, allows pause command - /// [priority] in range 0 <= priority <= 10 with 0 highest, defaults to 5 - /// [metaData] user data - /// [displayName] human readable name for this task - /// [creationTime] time of task creation, 'now' by default. - /// - /// A [ParallelDownloadTask] cannot be paused or resumed on failure - ParallelDownloadTask( - {super.taskId, - required dynamic url, - super.urlQueryParameters, - super.filename, - super.headers, - super.httpRequestMethod, - this.chunks = 1, - super.directory, - super.baseDirectory, - super.group, - super.updates, - super.requiresWiFi, - super.retries, - super.allowPause, - super.priority, - super.metaData, - super.displayName, - super.creationTime}) - : assert(url is String || url is List, - 'The `url` parameter must be a string or a list of strings'), - assert(url is String || (url is List && url.isNotEmpty), - 'The list of urls must not be empty'), - urls = url is String - ? [urlWithQueryParameters(url, urlQueryParameters)] - : List.from( - url.map((e) => urlWithQueryParameters(e, urlQueryParameters))), - super(url: url is String ? url : url.first) { - retriesRemaining = 0; // chunk tasks will retry instead, based on [retries] - } - - /// Creates [ParallelDownloadTask] object from [json] - ParallelDownloadTask.fromJson(super.json) - : assert( - json['taskType'] == 'ParallelDownloadTask', - 'The provided JSON map is not a ParallelDownloadTask, ' - 'because key "taskType" is not "ParallelDownloadTask".'), - urls = List.from(json['urls'] as List? ?? []), - chunks = json['chunks'] as int? ?? 1, - super.fromJson(); - - @override - Map toJson() => - {...super.toJson(), 'urls': urls, 'chunks': chunks}; - - @override - String get taskType => 'ParallelDownloadTask'; - - @override - ParallelDownloadTask copyWith( - {String? taskId, - String? url, - String? filename, - Map? headers, - String? httpRequestMethod, - Object? post, - String? directory, - BaseDirectory? baseDirectory, - String? group, - Updates? updates, - bool? requiresWiFi, - int? retries, - int? retriesRemaining, - bool? allowPause, - int? priority, - String? metaData, - String? displayName, - DateTime? creationTime}) => - ParallelDownloadTask( - taskId: taskId ?? this.taskId, - url: url ?? urls, - filename: filename ?? this.filename, - headers: headers ?? this.headers, - httpRequestMethod: httpRequestMethod ?? this.httpRequestMethod, - chunks: chunks, - directory: directory ?? this.directory, - baseDirectory: baseDirectory ?? this.baseDirectory, - group: group ?? this.group, - updates: updates ?? this.updates, - requiresWiFi: requiresWiFi ?? this.requiresWiFi, - retries: retries ?? this.retries, - allowPause: allowPause ?? this.allowPause, - priority: priority ?? this.priority, - metaData: metaData ?? this.metaData, - displayName: displayName ?? this.displayName, - creationTime: creationTime ?? this.creationTime) - ..retriesRemaining = retriesRemaining ?? this.retriesRemaining; -} - -/// Class for background requests that do not involve a file -/// -/// Closely resembles a Task, with fewer fields available during construction -final class DataTask extends Task { - /// Creates a [DataTask] that runs in the background, but does not involve a - /// file - /// - /// [taskId] must be unique. A unique id will be generated if omitted - /// [url] properly encoded if necessary, can include query parameters - /// [urlQueryParameters] may be added and will be appended to the [url], must - /// be properly encoded if necessary - /// [headers] an optional map of HTTP request headers - /// [httpRequestMethod] the HTTP request method used (e.g. GET, POST) - /// [post] String post body, encoded in utf8 - /// [json] if given will encode [json] to string and use as the [post] data - /// [contentType] sets the Content-Type header to this value. If omitted and - /// [post] is given, it will be set to 'text-plain; charset=utf-8' and if - /// [json] is given, it will be set to 'application/json] - /// [group] if set allows different callbacks or processing for different - /// groups - /// [updates] the kind of progress updates requested (only .status or none) - /// [requiresWiFi] if set, will not start download until WiFi is available. - /// If not set may start download over cellular network - /// [retries] if >0 will retry a failed download this many times - /// [priority] in range 0 <= priority <= 10 with 0 highest, defaults to 5 - /// [metaData] user data - /// [displayName] human readable name for this task - /// [creationTime] time of task creation, 'now' by default. - DataTask( - {String? taskId, - required super.url, - super.urlQueryParameters, - super.headers, - super.httpRequestMethod, - String? post, - Map? json, - String? contentType, - super.group, - super.updates, - super.requiresWiFi, - super.retries, - super.metaData, - super.displayName, - super.priority, - super.creationTime}) - : assert(const [Updates.status, Updates.none].contains(updates), - 'DataTasks can only provide status updates'), - super( - post: json != null ? jsonEncode(json) : post, - baseDirectory: BaseDirectory.temporary, - allowPause: false) { - // if no content-type header set, it is set to [contentType] or - // (if post or json is given) to text/plain or application/json - if (!headers.containsKey('Content-Type') && - !headers.containsKey('content-type')) { - try { - if (contentType != null) { - headers['Content-Type'] = contentType; - } else if ((post != null || json != null)) { - assert((post != null) ^ (json != null), - 'Only post or json can be set, not both'); - headers['Content-Type'] = - json != null ? 'application/json' : 'text/plain; charset=utf-8'; - } - } on UnsupportedError { - _log.warning( - 'Could not add Content-Type header as supplied header is const'); - } - } - } - - @override - Task copyWith( - {String? taskId, - String? url, - String? filename, - Map? headers, - String? httpRequestMethod, - Object? post, - String? directory, - BaseDirectory? baseDirectory, - String? group, - Updates? updates, - bool? requiresWiFi, - int? retries, - int? retriesRemaining, - bool? allowPause, - int? priority, - String? metaData, - String? displayName, - DateTime? creationTime}) => - DataTask( - taskId: taskId ?? this.taskId, - url: url ?? this.url, - headers: headers ?? this.headers, - httpRequestMethod: httpRequestMethod ?? this.httpRequestMethod, - post: post as String? ?? this.post, - group: group ?? this.group, - updates: updates ?? this.updates, - requiresWiFi: requiresWiFi ?? this.requiresWiFi, - retries: retries ?? this.retries, - priority: priority ?? this.priority, - metaData: metaData ?? this.metaData, - displayName: displayName ?? this.displayName, - creationTime: creationTime ?? this.creationTime) - ..retriesRemaining = retriesRemaining ?? this.retriesRemaining; - - /// Creates [DataTask] object from [json] - DataTask.fromJson(super.json) - : assert( - json['taskType'] == 'DataTask', - 'The provided JSON map is not a DataTask, ' - 'because key "taskType" is not "DataTask".'), - super.fromJson(); - - @override - String get taskType => 'DataTask'; -} diff --git a/lib/services/background_downloader/src/utils.dart b/lib/services/background_downloader/src/utils.dart deleted file mode 100644 index 45f9506..0000000 --- a/lib/services/background_downloader/src/utils.dart +++ /dev/null @@ -1,158 +0,0 @@ -// ignore_for_file: depend_on_referenced_packages - -import 'dart:io'; - -import 'package:logging/logging.dart'; -import 'package:path/path.dart' as path; - -import 'task.dart'; - -final _log = Logger('FileDownloader'); - -/// Return url String composed of the [url] and the -/// [urlQueryParameters], if given -String urlWithQueryParameters( - String url, Map? urlQueryParameters) { - if (urlQueryParameters == null || urlQueryParameters.isEmpty) { - return url; - } - final separator = url.contains('?') ? '&' : '?'; - return '$url$separator${urlQueryParameters.entries.map((e) => '${e.key}=${e.value}').join('&')}'; -} - -/// Parses the range in a Range header, and returns a Pair representing -/// the range. The format needs to be "bytes=10-20" -/// -/// A missing lower range is substituted with 0, and a missing upper -/// range with null. If the string cannot be parsed, returns (0, null) -(int, int?) parseRange(String rangeStr) { - final regex = RegExp(r'bytes=(\d*)-(\d*)'); - final match = regex.firstMatch(rangeStr); - if (match == null) { - return (0, null); - } - - final start = int.tryParse(match.group(1) ?? '') ?? 0; - final end = int.tryParse(match.group(2) ?? ''); - return (start, end); -} - -/// Returns the content length extracted from the [responseHeaders], or from -/// the [task] headers -int getContentLength(Map 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; -} - -/// Returns a copy of the [task] with the [Task.filename] property changed -/// to the filename suggested by the server, or derived from the url, or -/// unchanged. -/// -/// If [unique] is true, the filename is guaranteed not to already exist. This -/// is accomplished by adding a suffix to the suggested filename with a number, -/// e.g. "data (2).txt" -/// -/// The server-suggested filename is obtained from the [responseHeaders] entry -/// "Content-Disposition" according to RFC6266, or the last path segment of the -/// URL, or leaves the filename unchanged -Future taskWithSuggestedFilename( - DownloadTask task, Map responseHeaders, bool unique) { - /// Returns [DownloadTask] with a filename similar to the one - /// supplied, but unused. - /// - /// If [unique], filename will sequence up in "filename (8).txt" format, - /// otherwise returns the [task] - Future uniqueFilename(DownloadTask task, bool unique) async { - if (!unique) { - return task; - } - final sequenceRegEx = RegExp(r'\((\d+)\)\.?[^.]*$'); - final extensionRegEx = RegExp(r'\.[^.]*$'); - var newTask = task; - var filePath = await newTask.filePath(); - var exists = await File(filePath).exists(); - while (exists) { - final extension = - extensionRegEx.firstMatch(newTask.filename)?.group(0) ?? ''; - final match = sequenceRegEx.firstMatch(newTask.filename); - final newSequence = int.parse(match?.group(1) ?? "0") + 1; - final newFilename = match == null - ? '${path.basenameWithoutExtension(newTask.filename)} ($newSequence)$extension' - : '${newTask.filename.substring(0, match.start - 1)} ($newSequence)$extension'; - newTask = newTask.copyWith(filename: newFilename); - filePath = await newTask.filePath(); - exists = await File(filePath).exists(); - } - return newTask; - } - - // start of main function - try { - final disposition = responseHeaders.entries - .firstWhere( - (element) => element.key.toLowerCase() == 'content-disposition') - .value; - // Try filename*=UTF-8'language'"encodedFilename" - final encodedFilenameRegEx = RegExp( - 'filename\\*=\\s*([^\']+)\'([^\']*)\'"?([^"]+)"?', - caseSensitive: false); - var match = encodedFilenameRegEx.firstMatch(disposition); - if (match != null && - match.group(1)?.isNotEmpty == true && - match.group(3)?.isNotEmpty == true) { - try { - final suggestedFilename = match.group(1)?.toUpperCase() == 'UTF-8' - ? Uri.decodeComponent(match.group(3)!) - : match.group(3)!; - return uniqueFilename( - task.copyWith(filename: suggestedFilename), unique); - } on ArgumentError { - _log.finest( - 'Could not interpret suggested filename (UTF-8 url encoded) ${match.group(3)}'); - } - } - // Try filename="filename" - final plainFilenameRegEx = - RegExp(r'filename=\s*"?([^"]+)"?.*$', caseSensitive: false); - match = plainFilenameRegEx.firstMatch(disposition); - if (match != null && match.group(1)?.isNotEmpty == true) { - return uniqueFilename(task.copyWith(filename: match.group(1)), unique); - } - } catch (_) {} - _log.finest('Could not determine suggested filename from server'); - // Try filename derived from last path segment of the url - try { - final suggestedFilename = Uri.parse(task.url).pathSegments.last; - return uniqueFilename(task.copyWith(filename: suggestedFilename), unique); - } catch (_) {} - _log.finest('Could not parse URL pathSegment for suggested filename'); - // if everything fails, return the task with unchanged filename - // except for possibly making it unique - return uniqueFilename(task, unique); -} diff --git a/lib/services/download_manager/m3u8/m3u8_downloader.dart b/lib/services/download_manager/m3u8/m3u8_downloader.dart new file mode 100644 index 0000000..9e60b3a --- /dev/null +++ b/lib/services/download_manager/m3u8/m3u8_downloader.dart @@ -0,0 +1,420 @@ +import 'dart:collection'; +import 'dart:developer'; +import 'dart:io'; +import 'dart:async'; +import 'dart:isolate'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart'; +import 'package:mangayomi/models/chapter.dart'; +import 'package:mangayomi/services/http/m_client.dart'; +import 'package:mangayomi/services/http/rhttp/src/model/settings.dart'; +import 'package:mangayomi/services/download_manager/m3u8/models/download.dart'; +import 'package:mangayomi/services/download_manager/m3u8/models/ts_info.dart'; +import 'package:mangayomi/src/rust/frb_generated.dart'; +import 'package:mangayomi/utils/extensions/string_extensions.dart'; +import 'package:path/path.dart' as path; +import 'package:encrypt/encrypt.dart' as encrypt; +import 'package:convert/convert.dart'; + +final isolateChapsSendPorts = {}; + +class M3u8Downloader { + final String m3u8Url; + final String downloadDir; + final Map? headers; + final String fileName; + final int concurrentDownloads; + final Chapter chapter; + Isolate? _isolate; + ReceivePort? _receivePort; + static var httpClient = MClient.httpClient( + settings: const ClientSettings( + throwOnStatusCode: false, + tlsSettings: TlsSettings(verifyCertificates: false))); + M3u8Downloader({ + required this.m3u8Url, + required this.downloadDir, + required this.fileName, + this.headers, + required this.chapter, + this.concurrentDownloads = 15, + }); + + void _log(String message) { + if (kDebugMode) { + log('[M3u8Downloader] $message'); + } + } + + void close() { + _isolate?.kill(); + _receivePort?.close(); + } + + static _recreateClient() async { + await RustLib.init(); + httpClient = MClient.httpClient( + settings: const ClientSettings( + throwOnStatusCode: false, + tlsSettings: TlsSettings(verifyCertificates: false))); + } + + static Future _withRetryStatic( + Future Function() operation, int maxRetries) async { + int attempts = 0; + while (true) { + try { + attempts++; + return await operation(); + } catch (e) { + if (attempts >= maxRetries) { + throw M3u8DownloaderException( + 'Operation failed after $maxRetries attempts', e); + } + } + } + } + + Future _withRetry(Future Function() operation) async { + int attempts = 0; + while (true) { + try { + attempts++; + return await operation(); + } catch (e) { + if (attempts >= 3) { + throw M3u8DownloaderException('Operation failed after 3 attempts', e); + } + } + } + } + + Future<(List, Uint8List?, Uint8List?, int?)> _getTsList() async { + try { + final uri = Uri.parse(m3u8Url); + final m3u8Host = "${uri.scheme}://${uri.host}${path.dirname(uri.path)}"; + final m3u8Body = await _withRetry(() => _getM3u8Body(m3u8Url)); + final tsList = _parseTsList(m3u8Host, m3u8Body); + final mediaSequence = _extractMediaSequence(m3u8Body); + + _log("Total TS files to download: ${tsList.length}"); + + final (key, iv) = await _getM3u8KeyAndIv(m3u8Body); + if (key != null) _log("TS Key found"); + if (iv != null) _log("TS IV found"); + if (mediaSequence != null) _log("Media sequence: $mediaSequence"); + + return (tsList, key, iv, mediaSequence); + } catch (e) { + throw M3u8DownloaderException('Failed to get TS list', e); + } + } + + Future download(void Function(DownloadProgress) onProgress) async { + final tempDir = Directory(path.join(downloadDir, 'temp')); + + try { + await tempDir.create(recursive: true); + final (tsList, key, iv, mediaSequence) = await _getTsList(); + + final tsListToDownload = + await _filterExistingSegments(tsList, tempDir.path); + _log('Downloading ${tsListToDownload.length} segments...'); + + await _downloadSegmentsWithProgress( + tsListToDownload, tempDir.path, key, iv, mediaSequence, onProgress); + } catch (e) { + throw M3u8DownloaderException('Download failed', e); + } finally { + close(); + } + } + + Future> _filterExistingSegments( + List tsList, String tempDir) async { + return tsList + .where((ts) => !File(path.join(tempDir, '${ts.name}.ts')).existsSync()) + .toList(); + } + + Future _downloadSegmentsWithProgress( + List segments, + String tempDir, + Uint8List? key, + Uint8List? iv, + int? mediaSequence, + void Function(DownloadProgress) onProgress, + ) async { + _receivePort = ReceivePort(); + + final errorPort = ReceivePort(); + _isolate = await Isolate.spawn( + _downloadWorker, + DownloadParams( + segments: segments, + tempDir: tempDir, + key: key, + iv: iv, + mediaSequence: mediaSequence, + concurrentDownloads: concurrentDownloads, + headers: headers, + sendPort: _receivePort!.sendPort, + itemType: chapter.manga.value!.itemType, + ), + onError: errorPort.sendPort, + ); + isolateChapsSendPorts['${chapter.id}'] = (_receivePort, _isolate); + errorPort.listen((message) { + final stackTrace = message.last; + _log('Stack trace: $stackTrace'); + _receivePort!.close(); + }); + await for (final message in _receivePort!) { + if (message is DownloadProgress) { + onProgress.call(message); + } else if (message is DownloadComplete) { + await _mergeSegments(fileName, tempDir, onProgress); + if (await Directory(tempDir).exists()) { + try { + await Directory(tempDir).delete(recursive: true); + } catch (e) { + _log('Warning: Failed to clean up temporary directory: $e'); + } + } + errorPort.close(); + break; + } else if (message is Exception) { + errorPort.close(); + throw message; + } + } + } + + static void _downloadWorker(DownloadParams params) async { + await _recreateClient(); + int completed = 0; + final total = params.segments!.length; + final queue = Queue.from(params.segments!); + final List> activeTasks = []; + + try { + while (queue.isNotEmpty || activeTasks.isNotEmpty) { + while (queue.isNotEmpty && + activeTasks.length < params.concurrentDownloads!) { + final segment = queue.removeFirst(); + final task = _processSegment( + segment, + params, + httpClient, + ).then((_) { + completed++; + params.sendPort!.send(DownloadProgress( + segment: segment, completed, total, params.itemType!)); + }).catchError((error) { + params.sendPort!.send( + M3u8DownloaderException( + 'Error downloading segment ${segment.name}', error), + ); + throw error; + }); + + activeTasks.add(task); + } + + if (activeTasks.isNotEmpty) { + await Future.wait(activeTasks.toList(), eagerError: true); + activeTasks.clear(); + } + } + + params.sendPort!.send(DownloadComplete()); + } catch (e) { + params.sendPort!.send(M3u8DownloaderException('Download failed', e)); + } finally { + httpClient.close(); + } + } + + static Future _processSegment( + TsInfo ts, + DownloadParams params, + Client client, + ) async { + try { + final response = await _withRetryStatic( + () => client.get(Uri.parse(ts.url), headers: params.headers), 3); + if (response.statusCode != 200) { + throw M3u8DownloaderException('Failed to download segment: ${ts.name}'); + } + + final file = File(path.join('${params.tempDir}', '${ts.name}.ts')); + await file.writeAsBytes(response.bodyBytes); + + if (params.key != null) { + final bytes = await file.readAsBytes(); + final index = int.parse(ts.name.substringAfter("TS_")); + final decrypted = _aesDecryptStatic( + (params.mediaSequence ?? 1) + (index - 1), + bytes, + params.key!, + iv: params.iv, + ); + await file.writeAsBytes(decrypted); + } + } catch (e) { + throw M3u8DownloaderException('Failed to process segment: ${ts.name}', e); + } + } + + static Uint8List _aesDecryptStatic( + int sequence, + Uint8List encrypted, + Uint8List key, { + Uint8List? iv, + }) { + try { + if (iv == null) { + iv = Uint8List(16); + ByteData.view(iv.buffer).setUint64(8, sequence); + } + final encrypter = encrypt.Encrypter( + encrypt.AES(encrypt.Key(key), mode: encrypt.AESMode.cbc), + ); + return Uint8List.fromList( + encrypter.decryptBytes( + encrypt.Encrypted(encrypted), + iv: encrypt.IV(iv), + ), + ); + } catch (e) { + throw M3u8DownloaderException('Decryption failed', e); + } + } + + Future _mergeSegments(String outputFile, String tempDir, + void Function(DownloadProgress) onProgress) async { + _log('Merging segments...'); + try { + await _mergeTsToMp4(outputFile, tempDir); + onProgress.call(DownloadProgress(1, 1, chapter.manga.value!.itemType, + isCompleted: true)); + _log('Merge completed successfully'); + } catch (e) { + throw M3u8DownloaderException('Failed to merge segments', e); + } + } + + Future _mergeTsToMp4(String fileName, String directory) async { + try { + final dir = Directory(directory); + final files = await dir + .list() + .where((entity) => entity.path.endsWith('.ts')) + .toList(); + + files.sort((a, b) { + final aIndex = + int.parse(a.path.substringAfter("TS_").substringBefore(".")); + final bIndex = + int.parse(b.path.substringAfter("TS_").substringBefore(".")); + return aIndex.compareTo(bIndex); + }); + + final outFile = File(fileName).openWrite(); + for (var file in files) { + final bytes = await File(file.path).readAsBytes(); + outFile.add(bytes); + } + await outFile.close(); + } catch (e) { + throw M3u8DownloaderException('Failed to merge TS files', e); + } + } + + Future _getM3u8Body(String url) async { + final response = await httpClient.get(Uri.parse(url), headers: headers); + if (response.statusCode != 200) { + throw M3u8DownloaderException('Failed to load m3u8 body'); + } + return response.body; + } + + List _parseTsList(String host, String body) { + final lines = body.split('\n'); + final tsList = []; + var index = 0; + + for (final line in lines) { + if (line.isEmpty || line.startsWith('#')) continue; + index++; + final tsUrl = + line.startsWith('http') ? line : '$host${line.replaceFirst("/", "")}'; + tsList.add(TsInfo('TS_$index', tsUrl)); + } + return tsList; + } + + Future<(Uint8List?, Uint8List?)> _getM3u8KeyAndIv(String m3u8Body) async { + try { + final uri = Uri.parse(m3u8Url); + final m3u8Host = '${uri.scheme}://${uri.host}${path.dirname(uri.path)}'; + + for (final line in m3u8Body.split('\n')) { + if (!line.contains('#EXT-X-KEY')) continue; + + final (keyUrl, iv) = _extractKeyAttributes(line, m3u8Host); + if (keyUrl == null) break; + + final response = await _withRetry( + () => httpClient.get(Uri.parse(keyUrl), headers: headers), + ); + if (response.statusCode == 200) { + return (Uint8List.fromList(response.bodyBytes), iv); + } + } + return (null, null); + } catch (e) { + throw M3u8DownloaderException('Failed to get m3u8 key and IV', e); + } + } + + (String?, Uint8List?) _extractKeyAttributes(String content, String host) { + final keyPattern = RegExp( + r'#EXT-X-KEY:METHOD=AES-128(?:,URI="([^"]+)")?(?:,IV=0x([A-F0-9]+))?', + caseSensitive: false, + ); + final match = keyPattern.firstMatch(content); + if (match == null) return (null, null); + + String? uri = match.group(1); + if (uri != null && !uri.contains('http')) { + uri = '$host$uri'; + } + + final ivStr = match.group(2); + final iv = ivStr != null + ? Uint8List.fromList(hex.decode(ivStr.replaceFirst('0x', ''))) + : null; + + return (uri, iv); + } + + int? _extractMediaSequence(String content) { + for (final line in content.split('\n')) { + if (!line.startsWith('#EXT-X-MEDIA-SEQUENCE')) continue; + return int.tryParse(line.substringAfter(':').trim()); + } + return null; + } +} + +class M3u8DownloaderException implements Exception { + final String message; + final dynamic originalError; + + M3u8DownloaderException(this.message, [this.originalError]); + + @override + String toString() => + 'M3u8DownloaderException: $message${originalError != null ? ' ($originalError)' : ''}'; +} diff --git a/lib/services/download_manager/m3u8/models/download.dart b/lib/services/download_manager/m3u8/models/download.dart new file mode 100644 index 0000000..ab08e88 --- /dev/null +++ b/lib/services/download_manager/m3u8/models/download.dart @@ -0,0 +1,55 @@ +import 'dart:isolate'; +import 'dart:typed_data'; +import 'package:mangayomi/models/manga.dart'; +import 'package:mangayomi/models/page.dart'; +import 'package:mangayomi/services/download_manager/m3u8/models/ts_info.dart'; + +class DownloadParams { + final List? segments; + final String? tempDir; + final Uint8List? key; + final Uint8List? iv; + final int? mediaSequence; + final int? concurrentDownloads; + final Map? headers; + final SendPort? sendPort; + final List? pageUrls; + final ItemType? itemType; + + DownloadParams({ + this.segments, + this.tempDir, + this.key, + this.iv, + this.mediaSequence, + this.concurrentDownloads, + this.headers, + this.sendPort, + this.pageUrls, + this.itemType, + }); + + @override + String toString() { + return 'DownloadParams(segments: ${segments?.length}, tempDir: $tempDir, mediaSequence: $mediaSequence, concurrentDownloads: $concurrentDownloads, pageUrls: ${pageUrls?.length})'; + } +} + +class DownloadComplete {} + +class DownloadProgress { + TsInfo? segment; + PageUrl? pageUrl; + final int completed; + final int total; + bool isCompleted; + ItemType itemType; + + DownloadProgress(this.completed, this.total, this.itemType, + {this.segment, this.pageUrl, this.isCompleted = false}); + + @override + String toString() { + return 'DownloadProgress(segment: $segment, pageUrl: $pageUrl completed: $completed, total: $total, isCompleted: $isCompleted)'; + } +} diff --git a/lib/services/download_manager/m3u8/models/ts_info.dart b/lib/services/download_manager/m3u8/models/ts_info.dart new file mode 100644 index 0000000..2bf347a --- /dev/null +++ b/lib/services/download_manager/m3u8/models/ts_info.dart @@ -0,0 +1,9 @@ +class TsInfo { + final String name; + final String url; + + TsInfo(this.name, this.url); + + @override + String toString() => 'TsInfo(name: $name, url: $url)'; +} diff --git a/lib/services/download_manager/m_downloader.dart b/lib/services/download_manager/m_downloader.dart new file mode 100644 index 0000000..5f0ae4f --- /dev/null +++ b/lib/services/download_manager/m_downloader.dart @@ -0,0 +1,218 @@ +import 'dart:collection'; +import 'dart:developer'; +import 'dart:io'; +import 'dart:async'; +import 'dart:isolate'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart'; +import 'package:mangayomi/models/chapter.dart'; +import 'package:mangayomi/models/manga.dart'; +import 'package:mangayomi/models/page.dart'; +import 'package:mangayomi/services/http/m_client.dart'; +import 'package:mangayomi/services/http/rhttp/src/model/settings.dart'; +import 'package:mangayomi/services/download_manager/m3u8/m3u8_downloader.dart'; +import 'package:mangayomi/services/download_manager/m3u8/models/download.dart'; +import 'package:mangayomi/src/rust/frb_generated.dart'; + +class MDownloader { + List pageUrls; + final int concurrentDownloads; + final Chapter chapter; + Isolate? _isolate; + ReceivePort? _receivePort; + static var httpClient = MClient.httpClient( + settings: const ClientSettings( + throwOnStatusCode: false, + tlsSettings: TlsSettings(verifyCertificates: false))); + MDownloader({ + required this.chapter, + required this.pageUrls, + this.concurrentDownloads = 15, + }); + + void _log(String message) { + if (kDebugMode) { + log('[MDownloader] $message'); + } + } + + void close() { + _isolate?.kill(); + _receivePort?.close(); + } + + static _recreateClient() async { + await RustLib.init(); + httpClient = MClient.httpClient( + settings: const ClientSettings( + throwOnStatusCode: false, + tlsSettings: TlsSettings(verifyCertificates: false))); + } + + static Future _withRetryStatic( + Future Function() operation, int maxRetries) async { + int attempts = 0; + while (true) { + try { + attempts++; + return await operation(); + } catch (e) { + if (attempts >= maxRetries) { + throw M3u8DownloaderException( + 'Operation failed after $maxRetries attempts', e); + } + } + } + } + + Future download(void Function(DownloadProgress) onProgress) async { + try { + await _downloadFilesWithProgress(pageUrls, onProgress); + } catch (e) { + throw MDownloaderException('Download failed', e); + } finally { + close(); + } + } + + Future _downloadFilesWithProgress( + List pageUrls, + void Function(DownloadProgress) onProgress, + ) async { + _receivePort = ReceivePort(); + + final errorPort = ReceivePort(); + _isolate = await Isolate.spawn( + _downloadWorker, + DownloadParams( + pageUrls: pageUrls, + sendPort: _receivePort!.sendPort, + concurrentDownloads: concurrentDownloads, + itemType: chapter.manga.value!.itemType), + onError: errorPort.sendPort, + ); + isolateChapsSendPorts['${chapter.id}'] = (_receivePort, _isolate); + errorPort.listen((message) { + final stackTrace = message.last; + _log('Stack trace: $stackTrace'); + _receivePort!.close(); + }); + await for (final message in _receivePort!) { + if (message is DownloadProgress) { + onProgress.call(message); + } else if (message is DownloadComplete) { + onProgress.call(DownloadProgress(1, 1, chapter.manga.value!.itemType, + isCompleted: true)); + errorPort.close(); + break; + } else if (message is Exception) { + errorPort.close(); + throw message; + } + } + } + + static void _downloadWorker(DownloadParams params) async { + await _recreateClient(); + int completed = 0; + final total = params.pageUrls!.length; + final queue = Queue.from(params.pageUrls!); + final List> activeTasks = []; + + try { + while (queue.isNotEmpty || activeTasks.isNotEmpty) { + while (queue.isNotEmpty && + activeTasks.length < params.concurrentDownloads!) { + final pageUrl = queue.removeFirst(); + final task = _processFile(pageUrl, httpClient, params).then((_) { + if (params.itemType! != ItemType.anime) { + completed++; + params.sendPort!.send(DownloadProgress( + pageUrl: pageUrl, completed, total, params.itemType!)); + } + }).catchError((error) { + params.sendPort!.send( + MDownloaderException( + 'Error downloading ${pageUrl.fileName}', error), + ); + throw error; + }); + + activeTasks.add(task); + } + + if (activeTasks.isNotEmpty) { + await Future.wait(activeTasks.toList(), eagerError: true); + activeTasks.clear(); + } + } + + params.sendPort!.send(DownloadComplete()); + } catch (e) { + params.sendPort!.send(MDownloaderException('Download failed', e)); + } finally { + httpClient.close(); + } + } + + static Future _processFile( + PageUrl pageUrl, Client client, DownloadParams params) async { + try { + if (params.itemType! != ItemType.anime) { + final response = await _withRetryStatic( + () => client.get(Uri.parse(pageUrl.url), headers: pageUrl.headers), + 3); + if (response.statusCode != 200) { + throw MDownloaderException( + 'Failed to download file: ${pageUrl.fileName!}'); + } + + final file = File(pageUrl.fileName!); + await file.writeAsBytes(response.bodyBytes); + } else { + final bytes = await _withRetryStatic(() async { + List bytes = []; + var request = Request('GET', Uri.parse(pageUrl.url)); + request.headers.addAll(pageUrl.headers ?? {}); + StreamedResponse response = await client.send(request); + if (response.statusCode != 200) { + throw MDownloaderException( + 'Failed to download file: ${pageUrl.fileName!}'); + } + int total = response.contentLength ?? 0; + int recieved = 0; + + await for (var value in response.stream) { + bytes.addAll(value); + try { + recieved += value.length; + params.sendPort!.send(DownloadProgress( + (recieved / total * 100).toInt(), + 100, + pageUrl: pageUrl, + params.itemType!)); + } catch (_) {} + } + return bytes; + }, 3); + + final file = File(pageUrl.fileName!); + await file.writeAsBytes(bytes); + } + } catch (e) { + throw MDownloaderException( + 'Failed to process file: ${pageUrl.fileName!}', e); + } + } +} + +class MDownloaderException implements Exception { + final String message; + final dynamic originalError; + + MDownloaderException(this.message, [this.originalError]); + + @override + String toString() => + 'MDownloaderException: $message${originalError != null ? ' ($originalError)' : ''}'; +} diff --git a/lib/services/m3u8/m3u8_downloader.dart b/lib/services/m3u8/m3u8_downloader.dart deleted file mode 100644 index fe2c309..0000000 --- a/lib/services/m3u8/m3u8_downloader.dart +++ /dev/null @@ -1,195 +0,0 @@ -// ignore_for_file: depend_on_referenced_packages - -import 'dart:io'; -import 'dart:async'; -import 'dart:isolate'; -import 'package:flutter/foundation.dart'; -import 'package:mangayomi/services/http/m_client.dart'; -import 'package:mangayomi/utils/extensions/string_extensions.dart'; -import 'package:path/path.dart' as path; -import 'package:encrypt/encrypt.dart' as encrypt; -import 'package:convert/convert.dart'; - -class TsInfo { - final String name; - final String url; - TsInfo(this.name, this.url); -} - -class M3u8Downloader { - final String m3u8Url; - final String downloadDir; - final Map? headers; - M3u8Downloader( - {required this.m3u8Url, - required this.downloadDir, - required this.headers}); - - Future<(List, Uint8List?, Uint8List?, int?)> getTsList() async { - Uint8List? key; - Uint8List? iv; - int? mediaSequence; - final uri = Uri.parse(m3u8Url); - final m3u8Host = "${uri.scheme}://${uri.host}${path.dirname(uri.path)}"; - final m3u8Body = await _getM3u8Body(m3u8Url); - final tsList = _parseTsList(m3u8Host, m3u8Body); - mediaSequence = _extractMediaSequence(m3u8Body); - if (kDebugMode) { - print("Total TS files to download: ${tsList.length}"); - } - final (tsKey, tsIv) = await _getM3u8KeyAndIv(m3u8Body); - if (tsKey?.isNotEmpty ?? false) { - if (kDebugMode) { - print("TS Key: $tsKey"); - } - key = tsKey; - } - if (tsIv != null) { - if (kDebugMode) { - print("TS Iv: $tsIv"); - } - iv = Uint8List.fromList(hex.decode(tsIv.replaceFirst("0x", ""))); - } - if (mediaSequence != null) { - if (kDebugMode) { - print("Media sequence: $mediaSequence"); - } - } - return (tsList, key, iv, mediaSequence); - } - - Future _getM3u8Body( - String url, - ) async { - final response = - await MClient.httpClient().get(Uri.parse(url), headers: headers); - if (response.statusCode == 200) { - return response.body; - } else { - throw Exception("Failed to load m3u8 body"); - } - } - - List _parseTsList(String host, String body) { - final lines = body.split("\n"); - List tsList = []; - int index = 0; - for (final line in lines) { - if (!line.startsWith("#") && line.isNotEmpty) { - index++; - final tsUrl = line.startsWith("http") - ? line - : "$host/${line.replaceFirst("/", "")}"; - tsList.add(TsInfo("TS_$index", tsUrl)); - } - } - return tsList; - } - - Future<(Uint8List?, String?)> _getM3u8KeyAndIv(String m3u8Body) async { - final uri = Uri.parse(m3u8Url); - final m3u8Host = "${uri.scheme}://${uri.host}${path.dirname(uri.path)}"; - final lines = m3u8Body.split("\n"); - for (final line in lines) { - if (line.contains("#EXT-X-KEY")) { - final (keyUrl, iv) = _extractKeyAttributes(line, m3u8Host); - if (keyUrl != null) { - final response = await MClient.httpClient() - .get(Uri.parse(keyUrl), headers: headers); - if (response.statusCode == 200) { - return (response.bodyBytes, iv); - } - } else { - break; - } - } - } - return (null, null); - } - - (String?, String?) _extractKeyAttributes(String content, String host) { - final keyPattern = RegExp( - r'#EXT-X-KEY:METHOD=AES-128(?:,URI="([^"]+)")?(?:,IV=0x([A-F0-9]+))?', - caseSensitive: false); - final match = keyPattern.firstMatch(content); - - String? uri = match?.group(1); - if (uri != null) { - if (!uri.contains("http")) { - uri = "$host/$uri"; - } - } - final iv = match?.group(2); - - return (uri, iv); - } - - Uint8List _aesDecrypt(int sequence, Uint8List encrypted, Uint8List key, - {Uint8List? iv}) { - if (iv == null) { - iv = Uint8List(16); - ByteData.view(iv.buffer).setUint64(8, sequence); - } - - final encrypter = encrypt.Encrypter( - encrypt.AES(encrypt.Key(key), mode: encrypt.AESMode.cbc)); - - try { - final decrypted = encrypter.decryptBytes(encrypt.Encrypted(encrypted), - iv: encrypt.IV(iv)); - - return Uint8List.fromList(decrypted); - } catch (e) { - throw ArgumentError('Decryption failed: $e'); - } - } - - int? _extractMediaSequence(String content) { - final lines = content.split('\n'); - for (var line in lines) { - if (line.startsWith('#EXT-X-MEDIA-SEQUENCE')) { - final sequenceStr = line.substringAfter(':'); - return int.tryParse(sequenceStr.trim()); - } - } - return null; - } - - Future mergeTsToMp4(String fileName, String directory) async { - await Isolate.run(() async { - List tsPathList = []; - final outFile = File(fileName).openWrite(); - final dir = Directory(directory); - await for (var entity in dir.list()) { - if (entity is File && entity.path.endsWith('.ts')) { - tsPathList.add(entity.path); - } - } - tsPathList.sort((a, b) => - int.parse(a.substringAfter("TS_").substringBefore(".")).compareTo( - int.parse(b.substringAfter("TS_").substringBefore(".")))); - for (var path in tsPathList) { - final bytes = await File(path).readAsBytes(); - outFile.add(bytes); - } - await outFile.flush(); - await outFile.close(); - await dir.delete(recursive: true); - }); - } - - Future processBytes(File newFile, Uint8List? tsKey, Uint8List? tsIv, - int? m3u8Sequence) async { - await Isolate.run(() async { - Uint8List bytes = await newFile.readAsBytes(); - if (tsKey != null) { - final index = - int.parse(newFile.path.substringAfter("TS_").substringBefore(".")); - bytes = _aesDecrypt((m3u8Sequence ?? 1) + (index - 1), bytes, tsKey, - iv: tsIv); - } - - await newFile.writeAsBytes(bytes); - }); - } -} diff --git a/lib/services/trackers/anilist.g.dart b/lib/services/trackers/anilist.g.dart index 834afd6..558da2c 100644 --- a/lib/services/trackers/anilist.g.dart +++ b/lib/services/trackers/anilist.g.dart @@ -6,7 +6,7 @@ part of 'anilist.dart'; // RiverpodGenerator // ************************************************************************** -String _$anilistHash() => r'ddd07acc8d28d2aa95c942566109e9393ca9e5ed'; +String _$anilistHash() => r'70e8cd537270a9054a1ef72de117fc7ad5545218'; /// Copied from Dart SDK class _SystemHash { diff --git a/lib/utils/extensions/chapter.dart b/lib/utils/extensions/chapter.dart index 4821ef0..9f6541b 100644 --- a/lib/utils/extensions/chapter.dart +++ b/lib/utils/extensions/chapter.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:mangayomi/main.dart'; import 'package:mangayomi/models/chapter.dart'; +import 'package:mangayomi/models/download.dart'; import 'package:mangayomi/modules/manga/reader/providers/push_router.dart'; import 'package:mangayomi/modules/manga/reader/providers/reader_controller_provider.dart'; +import 'package:mangayomi/services/download_manager/m3u8/m3u8_downloader.dart'; extension ChapterExtension on Chapter { Future pushToReaderView(BuildContext context, @@ -22,4 +25,16 @@ extension ChapterExtension on Chapter { } } } + + void cancelDownloads(int? downloadId) { + final (receivePort, isolate) = isolateChapsSendPorts['$id'] ?? (null, null); + isolate?.kill(); + receivePort?.close(); + isar.writeTxnSync(() { + isar.downloads.deleteSync(id!); + if (downloadId != null) { + isar.downloads.deleteSync(downloadId); + } + }); + } } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 3c553d1..6a58ed3 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,6 +6,7 @@ import FlutterMacOS import Foundation import audio_session +import connectivity_plus import flutter_inappwebview_macos import flutter_qjs import flutter_web_auth_2 @@ -28,6 +29,7 @@ import window_to_front func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) + ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) FlutterQjsPlugin.register(with: registry.registrar(forPlugin: "FlutterQjsPlugin")) FlutterWebAuth2Plugin.register(with: registry.registrar(forPlugin: "FlutterWebAuth2Plugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index b7940b0..2f64bf4 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,6 +1,9 @@ PODS: - audio_session (0.0.1): - FlutterMacOS + - connectivity_plus (0.0.1): + - Flutter + - FlutterMacOS - flutter_inappwebview_macos (0.0.1): - FlutterMacOS - OrderedSet (~> 6.0.3) @@ -53,6 +56,7 @@ PODS: DEPENDENCIES: - audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`) + - connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/darwin`) - flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`) - flutter_qjs (from `Flutter/ephemeral/.symlinks/plugins/flutter_qjs/macos`) - flutter_web_auth_2 (from `Flutter/ephemeral/.symlinks/plugins/flutter_web_auth_2/macos`) @@ -83,6 +87,8 @@ SPEC REPOS: EXTERNAL SOURCES: audio_session: :path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos + connectivity_plus: + :path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/darwin flutter_inappwebview_macos: :path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos flutter_qjs: @@ -130,6 +136,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: audio_session: 48ab6500f7a5e7c64363e206565a5dfe5a0c1441 + connectivity_plus: 2256d3e20624a7749ed21653aafe291a46446fee flutter_inappwebview_macos: c2d68649f9f8f1831bfcd98d73fd6256366d9d1d flutter_qjs: cb2d0cba9deade1d03b89f6c432eac126f39482a flutter_web_auth_2: 62b08da29f15a20fa63f144234622a1488d45b65 diff --git a/pubspec.lock b/pubspec.lock index 4562c64..502522e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -254,8 +254,24 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" - convert: + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: "8a68739d3ee113e51ad35583fdf9ab82c55d09d693d3c39da1aebab87c938412" + url: "https://pub.dev" + source: hosted + version: "6.1.2" + connectivity_plus_platform_interface: dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + convert: + dependency: "direct main" description: name: convert sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 @@ -1151,6 +1167,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" numberpicker: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 6a2ab25..533d3d6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -78,6 +78,8 @@ dependencies: ref: main screen_brightness: ^2.0.1 flutter_widget_from_html: ^0.15.3 + convert: ^3.1.2 + connectivity_plus: ^6.1.2 dependency_overrides: http: ^1.2.2 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 109f20f..27f33e7 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,7 @@ #include "generated_plugin_registrant.h" +#include #include #include #include @@ -20,6 +21,8 @@ #include void RegisterPlugins(flutter::PluginRegistry* registry) { + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi")); FlutterQjsPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index f0b5384..cc3e883 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + connectivity_plus flutter_inappwebview_windows flutter_qjs isar_flutter_libs