feat: improve chapter download
This commit is contained in:
parent
3353a85b0a
commit
3eeb4777a6
64 changed files with 1009 additions and 8754 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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<String>? taskIds;
|
||||
|
||||
bool? isStartDownload;
|
||||
|
||||
final chapter = IsarLink<Chapter>();
|
||||
|
||||
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<String, dynamic> 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<String>();
|
||||
total = json['total'];
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'chapterId': chapterId,
|
||||
'failed': failed,
|
||||
'id': id,
|
||||
'isDownload': isDownload,
|
||||
'isStartDownload': isStartDownload,
|
||||
'mangaId': mangaId,
|
||||
'succeeded': succeeded,
|
||||
'taskIds': taskIds,
|
||||
'total': total
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Type, List<int>> 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<int> offsets,
|
||||
Map<Type, List<int>> 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<Type, List<int>> 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<P>(
|
|||
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<Download, Download, QWhereClause> {
|
|||
|
||||
extension DownloadQueryFilter
|
||||
on QueryBuilder<Download, Download, QFilterCondition> {
|
||||
QueryBuilder<Download, Download, QAfterFilterCondition> chapterIdIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNull(
|
||||
property: r'chapterId',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterFilterCondition> chapterIdIsNotNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNotNull(
|
||||
property: r'chapterId',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterFilterCondition> chapterIdEqualTo(
|
||||
int? value) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'chapterId',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterFilterCondition> chapterIdGreaterThan(
|
||||
int? value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'chapterId',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterFilterCondition> chapterIdLessThan(
|
||||
int? value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'chapterId',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterFilterCondition> 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<Download, Download, QAfterFilterCondition> failedIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNull(
|
||||
|
|
@ -515,75 +407,6 @@ extension DownloadQueryFilter
|
|||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterFilterCondition> mangaIdIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNull(
|
||||
property: r'mangaId',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterFilterCondition> mangaIdIsNotNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNotNull(
|
||||
property: r'mangaId',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterFilterCondition> mangaIdEqualTo(
|
||||
int? value) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'mangaId',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterFilterCondition> mangaIdGreaterThan(
|
||||
int? value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'mangaId',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterFilterCondition> mangaIdLessThan(
|
||||
int? value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'mangaId',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterFilterCondition> 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<Download, Download, QAfterFilterCondition> succeededIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNull(
|
||||
|
|
@ -653,242 +476,6 @@ extension DownloadQueryFilter
|
|||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterFilterCondition> taskIdsIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNull(
|
||||
property: r'taskIds',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterFilterCondition> taskIdsIsNotNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNotNull(
|
||||
property: r'taskIds',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterFilterCondition> taskIdsElementEqualTo(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'taskIds',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterFilterCondition>
|
||||
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<Download, Download, QAfterFilterCondition>
|
||||
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<Download, Download, QAfterFilterCondition> 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<Download, Download, QAfterFilterCondition>
|
||||
taskIdsElementStartsWith(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.startsWith(
|
||||
property: r'taskIds',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterFilterCondition>
|
||||
taskIdsElementEndsWith(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.endsWith(
|
||||
property: r'taskIds',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterFilterCondition>
|
||||
taskIdsElementContains(String value, {bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.contains(
|
||||
property: r'taskIds',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterFilterCondition> taskIdsElementMatches(
|
||||
String pattern,
|
||||
{bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.matches(
|
||||
property: r'taskIds',
|
||||
wildcard: pattern,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterFilterCondition>
|
||||
taskIdsElementIsEmpty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'taskIds',
|
||||
value: '',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterFilterCondition>
|
||||
taskIdsElementIsNotEmpty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||
property: r'taskIds',
|
||||
value: '',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterFilterCondition> taskIdsLengthEqualTo(
|
||||
int length) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.listLength(
|
||||
r'taskIds',
|
||||
length,
|
||||
true,
|
||||
length,
|
||||
true,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterFilterCondition> taskIdsIsEmpty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.listLength(
|
||||
r'taskIds',
|
||||
0,
|
||||
true,
|
||||
0,
|
||||
true,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterFilterCondition> taskIdsIsNotEmpty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.listLength(
|
||||
r'taskIds',
|
||||
0,
|
||||
false,
|
||||
999999,
|
||||
true,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterFilterCondition> taskIdsLengthLessThan(
|
||||
int length, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.listLength(
|
||||
r'taskIds',
|
||||
0,
|
||||
true,
|
||||
length,
|
||||
include,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterFilterCondition>
|
||||
taskIdsLengthGreaterThan(
|
||||
int length, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.listLength(
|
||||
r'taskIds',
|
||||
length,
|
||||
include,
|
||||
999999,
|
||||
true,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterFilterCondition> 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<Download, Download, QAfterFilterCondition> totalIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNull(
|
||||
|
|
@ -979,18 +566,6 @@ extension DownloadQueryLinks
|
|||
}
|
||||
|
||||
extension DownloadQuerySortBy on QueryBuilder<Download, Download, QSortBy> {
|
||||
QueryBuilder<Download, Download, QAfterSortBy> sortByChapterId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'chapterId', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterSortBy> sortByChapterIdDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'chapterId', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterSortBy> sortByFailed() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'failed', Sort.asc);
|
||||
|
|
@ -1027,18 +602,6 @@ extension DownloadQuerySortBy on QueryBuilder<Download, Download, QSortBy> {
|
|||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterSortBy> sortByMangaId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'mangaId', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterSortBy> sortByMangaIdDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'mangaId', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterSortBy> sortBySucceeded() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'succeeded', Sort.asc);
|
||||
|
|
@ -1066,18 +629,6 @@ extension DownloadQuerySortBy on QueryBuilder<Download, Download, QSortBy> {
|
|||
|
||||
extension DownloadQuerySortThenBy
|
||||
on QueryBuilder<Download, Download, QSortThenBy> {
|
||||
QueryBuilder<Download, Download, QAfterSortBy> thenByChapterId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'chapterId', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterSortBy> thenByChapterIdDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'chapterId', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterSortBy> thenByFailed() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'failed', Sort.asc);
|
||||
|
|
@ -1126,18 +677,6 @@ extension DownloadQuerySortThenBy
|
|||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterSortBy> thenByMangaId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'mangaId', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterSortBy> thenByMangaIdDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'mangaId', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QAfterSortBy> thenBySucceeded() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'succeeded', Sort.asc);
|
||||
|
|
@ -1165,12 +704,6 @@ extension DownloadQuerySortThenBy
|
|||
|
||||
extension DownloadQueryWhereDistinct
|
||||
on QueryBuilder<Download, Download, QDistinct> {
|
||||
QueryBuilder<Download, Download, QDistinct> distinctByChapterId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'chapterId');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QDistinct> distinctByFailed() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'failed');
|
||||
|
|
@ -1189,24 +722,12 @@ extension DownloadQueryWhereDistinct
|
|||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QDistinct> distinctByMangaId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'mangaId');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QDistinct> distinctBySucceeded() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'succeeded');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QDistinct> distinctByTaskIds() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'taskIds');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, Download, QDistinct> distinctByTotal() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'total');
|
||||
|
|
@ -1222,12 +743,6 @@ extension DownloadQueryProperty
|
|||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, int?, QQueryOperations> chapterIdProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'chapterId');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, int?, QQueryOperations> failedProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'failed');
|
||||
|
|
@ -1246,24 +761,12 @@ extension DownloadQueryProperty
|
|||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, int?, QQueryOperations> mangaIdProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'mangaId');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, int?, QQueryOperations> succeededProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'succeeded');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, List<String>?, QQueryOperations> taskIdsProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'taskIds');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Download, int?, QQueryOperations> totalProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'total');
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ import 'package:mangayomi/eval/javascript/http.dart';
|
|||
|
||||
class PageUrl {
|
||||
String url;
|
||||
String? fileName;
|
||||
Map<String, String>? headers;
|
||||
|
||||
PageUrl(this.url, {this.headers});
|
||||
PageUrl(this.url, {this.fileName, this.headers});
|
||||
factory PageUrl.fromJson(Map<String, dynamic> json) {
|
||||
return PageUrl(
|
||||
json['url'].toString().trim(),
|
||||
|
|
@ -12,4 +13,9 @@ class PageUrl {
|
|||
);
|
||||
}
|
||||
Map<String, dynamic> toJson() => {'url': url, 'headers': headers};
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PageUrl(url: $url, headers: $headers, fileName: $fileName)';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -812,11 +812,8 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen>
|
|||
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<LibraryScreen>
|
|||
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<LibraryScreen>
|
|||
isar.writeTxnSync(() {
|
||||
final download = isar.downloads
|
||||
.filter()
|
||||
.chapterIdEqualTo(chapter.id!)
|
||||
.idEqualTo(chapter.id!)
|
||||
.findAllSync();
|
||||
if (download.isNotEmpty) {
|
||||
isar.downloads.deleteSync(
|
||||
|
|
|
|||
|
|
@ -170,9 +170,7 @@ class _LibraryGridViewWidgetState extends State<LibraryGridViewWidget> {
|
|||
i++) {
|
||||
final entries = isar.downloads
|
||||
.filter()
|
||||
.idIsNotNull()
|
||||
.chapterIdEqualTo(entry
|
||||
.chapters
|
||||
.idEqualTo(entry.chapters
|
||||
.toList()[i]
|
||||
.id)
|
||||
.findAllSync();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -168,11 +168,8 @@ class _MangaDetailViewState extends ConsumerState<MangaDetailView>
|
|||
? 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<MangaDetailView>
|
|||
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<MangaDetailView>
|
|||
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<MangaDetailView>
|
|||
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<MangaDetailView>
|
|||
in ref.watch(chaptersListStateProvider)) {
|
||||
final entries = isar.downloads
|
||||
.filter()
|
||||
.idIsNotNull()
|
||||
.chapterIdEqualTo(chapter.id)
|
||||
.idEqualTo(chapter.id)
|
||||
.findAllSync();
|
||||
if (entries.isEmpty ||
|
||||
!entries.first.isDownload!) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1110,7 +1110,7 @@ class _ChapterSetIsReadStateProviderElement
|
|||
}
|
||||
|
||||
String _$chapterSetDownloadStateHash() =>
|
||||
r'496b93306bd41686daf09af7f7594ae697927005';
|
||||
r'321f00669a4644016076dcf5e007355d696d26e3';
|
||||
|
||||
abstract class _$ChapterSetDownloadState
|
||||
extends BuildlessAutoDisposeNotifier<void> {
|
||||
|
|
|
|||
|
|
@ -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<ChapterPageDownload>
|
||||
with AutomaticKeepAliveClientMixin<ChapterPageDownload> {
|
||||
List<String> _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<XFile> 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<ChapterPageDownload>
|
|||
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<ChapterPageDownload>
|
|||
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<ChapterPageDownload>
|
|||
if (value == 0) {
|
||||
_sendFile();
|
||||
} else if (value == 1) {
|
||||
_deleteFile();
|
||||
_deleteFile(download.id!);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
|
|
@ -151,8 +123,7 @@ class _ChapterPageDownloadState extends ConsumerState<ChapterPageDownload>
|
|||
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<ChapterPageDownload>
|
|||
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<ChapterPageDownload>
|
|||
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<ChapterPageDownload>
|
|||
curve: Curves.easeInOut,
|
||||
tween: Tween<double>(
|
||||
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<ChapterPageDownload>
|
|||
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<ChapterPageDownload>
|
|||
),
|
||||
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<ChapterPageDownload>
|
|||
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<ChapterPageDownload>
|
|||
),
|
||||
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<ChapterPageDownload>
|
|||
],
|
||||
));
|
||||
}
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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<List<PageUrl>> downloadChapter(
|
||||
Future<void> 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<PageUrl> pageUrls = [];
|
||||
List<DownloadTask> tasks = [];
|
||||
List<PageUrl> 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<String, String> videoHeader = {};
|
||||
Map<String, String> htmlHeader = {
|
||||
"Priority": "u=0, i",
|
||||
|
|
@ -76,24 +66,54 @@ Future<List<PageUrl>> downloadChapter(
|
|||
bool hasM3U8File = false;
|
||||
bool nonM3U8File = false;
|
||||
M3u8Downloader? m3u8Downloader;
|
||||
Uint8List? tsKey;
|
||||
Uint8List? tsIv;
|
||||
int? m3u8MediaSequence;
|
||||
|
||||
Future<void> 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<void> 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>? chapterPageUrls = [];
|
||||
|
|
@ -141,18 +161,16 @@ Future<List<PageUrl>> downloadChapter(
|
|||
hasM3U8File = nonM3U8File ? false : m3u8Urls.isNotEmpty;
|
||||
final videosUrls = nonM3U8File ? nonM3u8Urls : m3u8Urls;
|
||||
if (videosUrls.isNotEmpty) {
|
||||
List<TsInfo> 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<List<PageUrl>> 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<List<PageUrl>> 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AsyncValue<List<PageUrl>>> {
|
||||
class DownloadChapterFamily extends Family<AsyncValue<void>> {
|
||||
/// See also [downloadChapter].
|
||||
const DownloadChapterFamily();
|
||||
|
||||
|
|
@ -75,7 +75,7 @@ class DownloadChapterFamily extends Family<AsyncValue<List<PageUrl>>> {
|
|||
}
|
||||
|
||||
/// See also [downloadChapter].
|
||||
class DownloadChapterProvider extends AutoDisposeFutureProvider<List<PageUrl>> {
|
||||
class DownloadChapterProvider extends AutoDisposeFutureProvider<void> {
|
||||
/// See also [downloadChapter].
|
||||
DownloadChapterProvider({
|
||||
required Chapter chapter,
|
||||
|
|
@ -115,7 +115,7 @@ class DownloadChapterProvider extends AutoDisposeFutureProvider<List<PageUrl>> {
|
|||
|
||||
@override
|
||||
Override overrideWith(
|
||||
FutureOr<List<PageUrl>> Function(DownloadChapterRef provider) create,
|
||||
FutureOr<void> Function(DownloadChapterRef provider) create,
|
||||
) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
|
|
@ -133,7 +133,7 @@ class DownloadChapterProvider extends AutoDisposeFutureProvider<List<PageUrl>> {
|
|||
}
|
||||
|
||||
@override
|
||||
AutoDisposeFutureProviderElement<List<PageUrl>> createElement() {
|
||||
AutoDisposeFutureProviderElement<void> createElement() {
|
||||
return _DownloadChapterProviderElement(this);
|
||||
}
|
||||
|
||||
|
|
@ -156,7 +156,7 @@ class DownloadChapterProvider extends AutoDisposeFutureProvider<List<PageUrl>> {
|
|||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
mixin DownloadChapterRef on AutoDisposeFutureProviderRef<List<PageUrl>> {
|
||||
mixin DownloadChapterRef on AutoDisposeFutureProviderRef<void> {
|
||||
/// The parameter `chapter` of this provider.
|
||||
Chapter get chapter;
|
||||
|
||||
|
|
@ -165,8 +165,7 @@ mixin DownloadChapterRef on AutoDisposeFutureProviderRef<List<PageUrl>> {
|
|||
}
|
||||
|
||||
class _DownloadChapterProviderElement
|
||||
extends AutoDisposeFutureProviderElement<List<PageUrl>>
|
||||
with DownloadChapterRef {
|
||||
extends AutoDisposeFutureProviderElement<void> with DownloadChapterRef {
|
||||
_DownloadChapterProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ void restoreBackup(Ref ref, Map<String, dynamic> 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();
|
||||
|
|
|
|||
|
|
@ -173,7 +173,7 @@ class _DoRestoreProviderElement extends AutoDisposeProviderElement<void>
|
|||
BuildContext get context => (origin as DoRestoreProvider).context;
|
||||
}
|
||||
|
||||
String _$restoreBackupHash() => r'1cc45d864473761c65d4ce52074e4bd9c513e91d';
|
||||
String _$restoreBackupHash() => r'24405b9be28204324e47d6c1db34495d55a491d2';
|
||||
|
||||
/// See also [restoreBackup].
|
||||
@ProviderFor(restoreBackup)
|
||||
|
|
|
|||
|
|
@ -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!);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -59,7 +59,9 @@ class _MangaWebViewState extends ConsumerState<MangaWebView> {
|
|||
..launch(widget.url)
|
||||
..onClose.whenComplete(() {
|
||||
timer.cancel();
|
||||
Navigator.pop(context);
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
browser = MyInAppBrowser(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ part of 'aniskip.dart';
|
|||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$aniSkipHash() => r'2e5d19b025a2207ff64da7bf7908450ea9e5ff8c';
|
||||
String _$aniSkipHash() => r'887869b54e2e151633efd46da83bde845e14f421';
|
||||
|
||||
/// See also [AniSkip].
|
||||
@ProviderFor(AniSkip)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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';
|
||||
|
|
@ -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<bool>();
|
||||
|
||||
/// True when initialization is complete and downloader ready for use
|
||||
Future<bool> get ready => _readyCompleter.future;
|
||||
|
||||
/// Persistent storage
|
||||
late final PersistentStorage _storage;
|
||||
late final Database database;
|
||||
|
||||
/// Set of tasks that are in `waitingToRetry` status
|
||||
final tasksWaitingToRetry = <Task>{};
|
||||
|
||||
/// Completers used to check task completion in convenience functions
|
||||
final awaitTasks = <Task, Completer<TaskStatusUpdate>>{};
|
||||
|
||||
/// Registered short status callback for convenience down/upload tasks
|
||||
///
|
||||
/// Short callbacks omit the [Task] as they are available from the closure
|
||||
final _shortTaskStatusCallbacks = <String, void Function(TaskStatus)>{};
|
||||
|
||||
/// Registered short progress callback for convenience down/upload tasks
|
||||
///
|
||||
/// Short callbacks omit the [Task] as they are available from the closure
|
||||
final _shortTaskProgressCallbacks = <String, void Function(double)>{};
|
||||
|
||||
/// Registered [TaskStatusCallback] for convenience batch down/upload tasks
|
||||
final _taskStatusCallbacks = <String, TaskStatusCallback>{};
|
||||
|
||||
/// Registered [TaskProgressCallback] for convenience batch down/upload tasks
|
||||
final _taskProgressCallbacks = <String, TaskProgressCallback>{};
|
||||
|
||||
/// Registered [TaskStatusCallback] for each group
|
||||
final groupStatusCallbacks = <String, TaskStatusCallback>{};
|
||||
|
||||
/// Registered [TaskProgressCallback] for each group
|
||||
final groupProgressCallbacks = <String, TaskProgressCallback>{};
|
||||
|
||||
/// Active batches
|
||||
final _batches = <Batch>[];
|
||||
|
||||
/// Registered [TaskNotificationTapCallback] for each group
|
||||
final groupNotificationTapCallbacks = <String, TaskNotificationTapCallback>{};
|
||||
|
||||
/// List of notification configurations
|
||||
final notificationConfigs = <TaskNotificationConfig>[];
|
||||
|
||||
/// StreamController for [TaskUpdate] updates
|
||||
var updates = StreamController<TaskUpdate>();
|
||||
|
||||
/// Groups tracked in persistent database
|
||||
final trackedGroups = <String?>{};
|
||||
|
||||
/// Map of tasks and completer to indicate whether task can be resumed
|
||||
final canResumeTask = <Task, Completer<bool>>{};
|
||||
|
||||
/// Flag indicating we have retrieved missed data
|
||||
@visibleForTesting
|
||||
var retrievedLocallyStoredData = false;
|
||||
|
||||
/// Connected TaskQueues that will receive a signal upon task completion
|
||||
final taskQueues = <TaskQueue>[];
|
||||
|
||||
BaseDownloader();
|
||||
|
||||
factory BaseDownloader.instance(
|
||||
PersistentStorage persistentStorage, Database database) {
|
||||
final instance = 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<void> 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<List<(String, String)>> configure(
|
||||
{dynamic globalConfig,
|
||||
dynamic androidConfig,
|
||||
dynamic iOSConfig,
|
||||
dynamic desktopConfig}) async {
|
||||
final global = globalConfig is List ? globalConfig : [globalConfig];
|
||||
final rawPlatformConfig = platformConfig(
|
||||
androidConfig: androidConfig,
|
||||
iOSConfig: iOSConfig,
|
||||
desktopConfig: desktopConfig);
|
||||
final platform =
|
||||
rawPlatformConfig is List ? rawPlatformConfig : [rawPlatformConfig];
|
||||
return await Future.wait([...global, ...platform]
|
||||
.where((e) => e != null)
|
||||
.map((e) => configureItem(e)));
|
||||
}
|
||||
|
||||
/// Returns the config for the platform, e.g. the [androidConfig] parameter
|
||||
/// on Android
|
||||
dynamic platformConfig(
|
||||
{dynamic globalConfig,
|
||||
dynamic androidConfig,
|
||||
dynamic iOSConfig,
|
||||
dynamic desktopConfig});
|
||||
|
||||
/// Configures one [configItem] and returns the (String, String) result
|
||||
///
|
||||
/// If the second element is '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<void> 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<bool> 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<TaskStatusUpdate> 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<TaskStatusUpdate>();
|
||||
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<Batch> enqueueAndAwaitBatch(final List<Task> 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 = <Future<TaskStatusUpdate>>[];
|
||||
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<int> reset(String group) async {
|
||||
final retryCount =
|
||||
tasksWaitingToRetry.where((task) => task.group == group).length;
|
||||
tasksWaitingToRetry.removeWhere((task) => task.group == group);
|
||||
final pausedTasks = await getPausedTasks();
|
||||
var pausedCount = 0;
|
||||
for (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<List<Task>> allTasks(
|
||||
String group, bool includeTasksWaitingToRetry) async {
|
||||
final tasks = <Task>[];
|
||||
if (includeTasksWaitingToRetry) {
|
||||
tasks.addAll(tasksWaitingToRetry.where((task) => task.group == group));
|
||||
}
|
||||
final pausedTasks = await getPausedTasks();
|
||||
tasks.addAll(pausedTasks.where((task) => task.group == group));
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/// Cancels ongoing tasks whose taskId is in the list provided with this call
|
||||
///
|
||||
/// Returns true if all cancellations were successful
|
||||
@mustCallSuper
|
||||
Future<bool> cancelTasksWithIds(List<String> taskIds) async {
|
||||
final matchingTasksWaitingToRetry = tasksWaitingToRetry
|
||||
.where((task) => taskIds.contains(task.taskId))
|
||||
.toList(growable: false);
|
||||
final matchingTaskIdsWaitingToRetry = matchingTasksWaitingToRetry
|
||||
.map((task) => task.taskId)
|
||||
.toList(growable: false);
|
||||
// remove tasks waiting to retry from the list so they won't be retried
|
||||
for (final task in matchingTasksWaitingToRetry) {
|
||||
tasksWaitingToRetry.remove(task);
|
||||
processStatusUpdate(TaskStatusUpdate(task, TaskStatus.canceled));
|
||||
processProgressUpdate(TaskProgressUpdate(task, progressCanceled));
|
||||
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<bool> cancelPlatformTasksWithIds(List<String> taskIds);
|
||||
|
||||
/// Cancel paused tasks
|
||||
///
|
||||
/// Deletes the associated temp file and emits [TaskStatus.cancel]
|
||||
Future<void> cancelPausedPlatformTasksWithIds(
|
||||
List<Task> pausedTasks, List<String> taskIds) async {
|
||||
for (final taskId in taskIds) {
|
||||
final task =
|
||||
pausedTasks.firstWhereOrNull((element) => element.taskId == taskId);
|
||||
if (task != null) {
|
||||
final resumeData = await getResumeData(task.taskId);
|
||||
if (!Platform.isIOS && resumeData != null) {
|
||||
final tempFilePath = resumeData.tempFilepath;
|
||||
try {
|
||||
await File(tempFilePath).delete();
|
||||
} on FileSystemException {
|
||||
log.fine('Could not delete temp file $tempFilePath');
|
||||
}
|
||||
}
|
||||
processStatusUpdate(TaskStatusUpdate(task, TaskStatus.canceled));
|
||||
processProgressUpdate(TaskProgressUpdate(task, progressCanceled));
|
||||
updateNotification(task, null); // remove notification
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns Task for this taskId, or nil
|
||||
@mustCallSuper
|
||||
Future<Task?> taskForId(String taskId) async {
|
||||
try {
|
||||
return tasksWaitingToRetry.where((task) => task.taskId == taskId).first;
|
||||
} on StateError {
|
||||
try {
|
||||
final pausedTasks = await getPausedTasks();
|
||||
return pausedTasks.where((task) => task.taskId == taskId).first;
|
||||
} on StateError {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Activate tracking for tasks in this group
|
||||
///
|
||||
/// All subsequent tasks in this group will be recorded in persistent storage
|
||||
/// and can be queried with methods that include 'tracked', e.g.
|
||||
/// [allTrackedTasks]
|
||||
///
|
||||
/// If [markDownloadedComplete] is true (default) then all tasks that are
|
||||
/// marked as not yet [TaskStatus.complete] will be set to complete if the
|
||||
/// target file for that task exists, and will emit [TaskStatus.complete]
|
||||
/// and [progressComplete] to their registered listener or callback.
|
||||
/// This is a convenient way to capture downloads that have completed while
|
||||
/// the app was suspended, provided you have registered your listeners
|
||||
/// or callback before calling this.
|
||||
Future<void> trackTasks(String? group, bool markDownloadedComplete) async {
|
||||
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<bool> pause(Task task);
|
||||
|
||||
/// Attempt to resume this [task]
|
||||
///
|
||||
/// Returns true if successful
|
||||
@mustCallSuper
|
||||
Future<bool> resume(Task task) async {
|
||||
await removePausedTask(task.taskId);
|
||||
if (await getResumeData(task.taskId) != null) {
|
||||
final currentCompleter = canResumeTask[task];
|
||||
if (currentCompleter == null || currentCompleter.isCompleted) {
|
||||
// create if didn't exist or was completed
|
||||
canResumeTask[task] = Completer();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 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<bool> requireWiFi(RequireWiFi requirement, rescheduleRunningTasks) =>
|
||||
Future.value(true);
|
||||
|
||||
/// Returns the current global setting for requiring WiFi
|
||||
Future<RequireWiFi> 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<bool> taskCanResume(Task task) async {
|
||||
if (await getResumeData(task.taskId) != null) {
|
||||
return true;
|
||||
}
|
||||
if (canResumeTask.containsKey(task)) {
|
||||
return canResumeTask[task]!.future;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Stores the resume data
|
||||
Future<void> setResumeData(ResumeData resumeData) =>
|
||||
_storage.storeResumeData(resumeData);
|
||||
|
||||
/// Retrieve the resume data for this [taskId]
|
||||
Future<ResumeData?> getResumeData(String taskId) =>
|
||||
_storage.retrieveResumeData(taskId);
|
||||
|
||||
/// Remove resumeData for this [taskId], or all if null
|
||||
Future<void> removeResumeData([String? taskId]) =>
|
||||
_storage.removeResumeData(taskId);
|
||||
|
||||
/// Store the paused [task]
|
||||
Future<void> setPausedTask(Task task) => _storage.storePausedTask(task);
|
||||
|
||||
/// Return a stored paused task with this [taskId], or null if not found
|
||||
Future<Task?> getPausedTask(String taskId) =>
|
||||
_storage.retrievePausedTask(taskId);
|
||||
|
||||
/// Return a list of paused [Task] objects
|
||||
Future<List<Task>> getPausedTasks() => _storage.retrieveAllPausedTasks();
|
||||
|
||||
/// Remove paused task for this taskId, or all if null
|
||||
Future<void> removePausedTask([String? taskId]) =>
|
||||
_storage.removePausedTask(taskId);
|
||||
|
||||
/// Retrieve data that was not delivered to Dart
|
||||
Future<Map<String, String>> popUndeliveredData(Undelivered dataType);
|
||||
|
||||
/// Clear pause and resume info associated with this [task]
|
||||
void _clearPauseResumeInfo(Task task) {
|
||||
canResumeTask.remove(task);
|
||||
removeResumeData(task.taskId);
|
||||
removePausedTask(task.taskId);
|
||||
}
|
||||
|
||||
/// Move the file at [filePath] to the shared storage
|
||||
/// [destination] and potential subdirectory [directory]
|
||||
///
|
||||
/// Returns the path to the file in shared storage, or null
|
||||
Future<String?> moveToSharedStorage(String filePath,
|
||||
SharedStorage destination, String directory, String? mimeType) {
|
||||
return Future.value(null);
|
||||
}
|
||||
|
||||
/// Returns the path to the file at [filePath] in shared storage
|
||||
/// [destination] and potential subdirectory [directory], or null
|
||||
Future<String?> pathInSharedStorage(
|
||||
String filePath, SharedStorage destination, String directory) {
|
||||
return Future.value(null);
|
||||
}
|
||||
|
||||
/// Open the file represented by [task] or [filePath] using the application
|
||||
/// available on the platform.
|
||||
///
|
||||
/// [mimeType] may override the mimetype derived from the file extension,
|
||||
/// though implementation depends on the platform and may not always work.
|
||||
///
|
||||
/// Returns true if an application was launched successfully
|
||||
///
|
||||
/// Precondition: either task or filename is not null
|
||||
Future<bool> openFile(Task? task, String? filePath, String? mimeType);
|
||||
|
||||
/// Return the platform version as a String
|
||||
Future<String> platformVersion() =>
|
||||
Future.value(Platform.operatingSystemVersion);
|
||||
|
||||
// Testing methods
|
||||
|
||||
/// Get the duration for a task to timeout - Android only, for testing
|
||||
@visibleForTesting
|
||||
Future<Duration> getTaskTimeout();
|
||||
|
||||
/// Set forceFailPostOnBackgroundChannel for native downloader
|
||||
@visibleForTesting
|
||||
Future<void> setForceFailPostOnBackgroundChannel(bool value);
|
||||
|
||||
/// Test suggested filename based on task and content disposition header
|
||||
@visibleForTesting
|
||||
Future<String> testSuggestedFilename(
|
||||
DownloadTask task, String contentDisposition);
|
||||
|
||||
// Helper methods
|
||||
|
||||
/// Closes the [updates] stream and re-initializes the [StreamController]
|
||||
/// such that the stream can be listened to again
|
||||
Future<void> resetUpdatesStreamController() async {
|
||||
if (updates.hasListener && !updates.isPaused) {
|
||||
await updates.close();
|
||||
}
|
||||
updates = StreamController();
|
||||
}
|
||||
|
||||
/// Process status update coming from Downloader and emit to listener
|
||||
///
|
||||
/// Also manages retries ([tasksWaitingToRetry] and delay) and pause/resume
|
||||
/// ([pausedTasks] and [_clearPauseResumeInfo]
|
||||
void processStatusUpdate(TaskStatusUpdate update) {
|
||||
// Normal status updates are only sent here when the task is expected
|
||||
// to provide those. The exception is a .failed status when a task
|
||||
// has retriesRemaining > 0: those are always sent here, and are
|
||||
// intercepted to hold the task and reschedule in the near future
|
||||
final task = update.task;
|
||||
if (update.status == TaskStatus.failed && task.retriesRemaining > 0) {
|
||||
_emitStatusUpdate(TaskStatusUpdate(task, TaskStatus.waitingToRetry));
|
||||
_emitProgressUpdate(TaskProgressUpdate(task, progressWaitingToRetry));
|
||||
task.decreaseRetriesRemaining();
|
||||
tasksWaitingToRetry.add(task);
|
||||
final waitTime = Duration(
|
||||
seconds: 2 << min(task.retries - task.retriesRemaining - 1, 8));
|
||||
log.finer('TaskId ${task.taskId} failed, waiting ${waitTime.inSeconds}'
|
||||
' seconds before retrying. ${task.retriesRemaining}'
|
||||
' retries remaining');
|
||||
Future.delayed(waitTime, () async {
|
||||
// after delay, resume or enqueue task again if it's still waiting
|
||||
if (tasksWaitingToRetry.remove(task)) {
|
||||
if (!((await getResumeData(task.taskId) != null &&
|
||||
await resume(task)) ||
|
||||
await enqueue(task))) {
|
||||
log.warning(
|
||||
'Could not resume/enqueue taskId ${task.taskId} after retry timeout');
|
||||
_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<void> processNotificationTap(
|
||||
Task task, NotificationType notificationType) async {
|
||||
var retries = 0;
|
||||
var success = false;
|
||||
while (retries < 5 && !success) {
|
||||
final notificationTapCallback = groupNotificationTapCallbacks[task.group];
|
||||
if (notificationTapCallback != null) {
|
||||
notificationTapCallback(task, notificationType);
|
||||
success = true;
|
||||
} else {
|
||||
await Future.delayed(
|
||||
Duration(milliseconds: 100 * pow(2, retries).round()));
|
||||
retries++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits the status update for this task to its callback or listener, and
|
||||
/// update the task in the database
|
||||
void _emitStatusUpdate(TaskStatusUpdate update) {
|
||||
final task = update.task;
|
||||
_updateTaskInDatabase(task,
|
||||
status: update.status, taskException: update.exception);
|
||||
if (task.providesStatusUpdates) {
|
||||
// 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<void> _updateTaskInDatabase(Task task,
|
||||
{TaskStatus? status,
|
||||
double? progress,
|
||||
int expectedFileSize = -1,
|
||||
TaskException? taskException}) async {
|
||||
if (trackedGroups.contains(null) || trackedGroups.contains(task.group)) {
|
||||
if (status == null && progress != null) {
|
||||
// update existing record with progress only (provided it's not 'paused')
|
||||
final existingRecord = await database.recordForId(task.taskId);
|
||||
if (existingRecord != null && progress != progressPaused) {
|
||||
database.updateRecord(existingRecord.copyWith(progress: progress));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (progress == null && status != null) {
|
||||
// set progress based on status
|
||||
progress = switch (status) {
|
||||
TaskStatus.enqueued || TaskStatus.running => 0.0,
|
||||
TaskStatus.complete => progressComplete,
|
||||
TaskStatus.notFound => progressNotFound,
|
||||
TaskStatus.failed => progressFailed,
|
||||
TaskStatus.canceled => progressCanceled,
|
||||
TaskStatus.waitingToRetry => progressWaitingToRetry,
|
||||
TaskStatus.paused => progressPaused
|
||||
};
|
||||
}
|
||||
if (status != TaskStatus.paused) {
|
||||
database.updateRecord(TaskRecord(
|
||||
task, status!, progress!, expectedFileSize, taskException));
|
||||
} else {
|
||||
// if paused, don't modify the stored progress
|
||||
final existingRecord = await database.recordForId(task.taskId);
|
||||
database.updateRecord(TaskRecord(task, status!,
|
||||
existingRecord?.progress ?? 0, expectedFileSize, taskException));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Destroy - clears callbacks, updates stream and retry queue
|
||||
///
|
||||
/// Clears all queues and references without sending cancellation
|
||||
/// messages or status updates
|
||||
@mustCallSuper
|
||||
void destroy() {
|
||||
tasksWaitingToRetry.clear();
|
||||
_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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, dynamic> json)
|
||||
: parentTaskId = json['parentTaskId'],
|
||||
url = json['url'],
|
||||
filename = json['filename'],
|
||||
fromByte = (json['fromByte'] as num).toInt(),
|
||||
toByte = (json['toByte'] as num).toInt(),
|
||||
task = Task.createFromJson(json['task']) as DownloadTask,
|
||||
status = TaskStatus.values[(json['status'] as num? ?? 0).toInt()],
|
||||
progress = (json['progress'] as num? ?? 0.0).toDouble();
|
||||
|
||||
/// Revive List<Chunk> from a JSON map in a jsonDecode operation,
|
||||
/// where each element is a map representing the [Chunk]
|
||||
static Object? listReviver(Object? key, Object? value) =>
|
||||
key is int ? Chunk.fromJson(value as Map<String, dynamic>) : value;
|
||||
|
||||
/// Creates JSON map of this object
|
||||
Map<String, dynamic> toJson() => {
|
||||
'parentTaskId': parentTaskId,
|
||||
'url': url,
|
||||
'filename': filename,
|
||||
'fromByte': fromByte,
|
||||
'toByte': toByte,
|
||||
'task': task.toJson(),
|
||||
'status': status.index,
|
||||
'progress': progress
|
||||
};
|
||||
|
||||
/// Return the parentTaskId embedded in the metaData of a chunkTask
|
||||
static String getParentTaskId(Task task) =>
|
||||
jsonDecode(task.metaData)['parentTaskId'] as String;
|
||||
|
||||
/// Return [Updates] that is based on the [parentTask]
|
||||
static Updates updatesBasedOnParent(Task parentTask) =>
|
||||
switch (parentTask.updates) {
|
||||
Updates.none || Updates.status => Updates.status,
|
||||
Updates.progress ||
|
||||
Updates.statusAndProgress =>
|
||||
Updates.statusAndProgress
|
||||
};
|
||||
}
|
||||
|
||||
/// Resume all chunk tasks associated with this [task], and
|
||||
/// return true if successful, otherwise cancels this [task]
|
||||
/// which will also cancel all chunk tasks
|
||||
Future<bool> resumeChunkTasks(
|
||||
ParallelDownloadTask task, ResumeData resumeData) async {
|
||||
final chunks =
|
||||
List<Chunk>.from(jsonDecode(resumeData.data, reviver: Chunk.listReviver));
|
||||
final results = await Future.wait(
|
||||
chunks.map((chunk) => FileDownloader().resume(chunk.task)));
|
||||
if (results.any((result) => result == false)) {
|
||||
// cancel [ParallelDownloadTask] if any resume did not succeed.
|
||||
// this will also cancel all chunk tasks
|
||||
await FileDownloader().cancelTaskWithId(task.taskId);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -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<List<TaskRecord>> allRecords({String? group}) async {
|
||||
final allRecords = await _storage.retrieveAllTaskRecords();
|
||||
return group == null
|
||||
? allRecords.toList()
|
||||
: allRecords.where((element) => element.group == group).toList();
|
||||
}
|
||||
|
||||
/// Returns all [TaskRecord] older than [age]
|
||||
///
|
||||
/// Optionally, specify a [group] to filter by
|
||||
Future<List<TaskRecord>> allRecordsOlderThan(Duration age,
|
||||
{String? group}) async {
|
||||
final allRecordsInGroup = await allRecords(group: group);
|
||||
final now = DateTime.now();
|
||||
return allRecordsInGroup
|
||||
.where((record) => now.difference(record.task.creationTime) > age)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Returns all [TaskRecord] with [TaskStatus] [status]
|
||||
///
|
||||
/// Optionally, specify a [group] to filter by
|
||||
Future<List<TaskRecord>> allRecordsWithStatus(TaskStatus status,
|
||||
{String? group}) async {
|
||||
final allRecordsInGroup = await allRecords(group: group);
|
||||
return allRecordsInGroup
|
||||
.where((record) => record.status == status)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Return [TaskRecord] for this [taskId] or null if not found
|
||||
Future<TaskRecord?> recordForId(String taskId) =>
|
||||
_storage.retrieveTaskRecord(taskId);
|
||||
|
||||
/// Return list of [TaskRecord] corresponding to the [taskIds]
|
||||
///
|
||||
/// Only records that can be found in the database will be included in the
|
||||
/// list. TaskIds that cannot be found will be ignored.
|
||||
Future<List<TaskRecord>> recordsForIds(Iterable<String> taskIds) async {
|
||||
final result = <TaskRecord>[];
|
||||
for (var taskId in taskIds) {
|
||||
final record = await recordForId(taskId);
|
||||
if (record != null) {
|
||||
result.add(record);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Delete all records
|
||||
///
|
||||
/// Optionally, specify a [group] to filter by
|
||||
Future<void> deleteAllRecords({String? group}) async {
|
||||
if (group == null) {
|
||||
await _storage.removeTaskRecord(null);
|
||||
return;
|
||||
}
|
||||
final allRecordsInGroup = await allRecords(group: group);
|
||||
await deleteRecordsWithIds(
|
||||
allRecordsInGroup.map((record) => record.taskId));
|
||||
}
|
||||
|
||||
/// Delete record with this [taskId]
|
||||
Future<void> deleteRecordWithId(String taskId) =>
|
||||
deleteRecordsWithIds([taskId]);
|
||||
|
||||
/// Delete records with these [taskIds]
|
||||
Future<void> deleteRecordsWithIds(Iterable<String> taskIds) async {
|
||||
for (var taskId in taskIds) {
|
||||
await _storage.removeTaskRecord(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update or insert the record in the database
|
||||
///
|
||||
/// This is used by the [FileDownloader] to track tasks, and should not
|
||||
/// normally be used by the user of this package
|
||||
Future<void> updateRecord(TaskRecord record) async =>
|
||||
_storage.storeTaskRecord(record);
|
||||
}
|
||||
|
||||
/// Record containing task, task status and task progress.
|
||||
///
|
||||
/// [TaskRecord] represents the state of the task as recorded in persistent
|
||||
/// storage if [trackTasks] has been called to activate this.
|
||||
final class TaskRecord {
|
||||
final Task task;
|
||||
final TaskStatus status;
|
||||
final double progress;
|
||||
final int expectedFileSize;
|
||||
final TaskException? exception;
|
||||
|
||||
TaskRecord(this.task, this.status, this.progress, this.expectedFileSize,
|
||||
[this.exception]);
|
||||
|
||||
/// Returns the group collection this record is stored under, which is
|
||||
/// the [task]'s [Task.group]
|
||||
String get group => task.group;
|
||||
|
||||
/// Returns the record id, which is the [task]'s [Task.taskId]
|
||||
String get taskId => task.taskId;
|
||||
|
||||
/// Create [TaskRecord] from [json]
|
||||
TaskRecord.fromJson(Map<String, dynamic> json)
|
||||
: task = Task.createFromJson(json),
|
||||
status = TaskStatus.values[
|
||||
(json['status'] as num?)?.toInt() ?? TaskStatus.failed.index],
|
||||
progress = (json['progress'] as num?)?.toDouble() ?? progressFailed,
|
||||
expectedFileSize = (json['expectedFileSize'] as num?)?.toInt() ?? -1,
|
||||
exception = json['exception'] == null
|
||||
? null
|
||||
: TaskException.fromJson(json['exception']);
|
||||
|
||||
/// Returns JSON map representation of this [TaskRecord]
|
||||
///
|
||||
/// Note the [status], [progress] and [exception] fields are merged into
|
||||
/// the JSON map representation of the [task]
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = task.toJson();
|
||||
json['status'] = status.index;
|
||||
json['progress'] = progress;
|
||||
json['expectedFileSize'] = expectedFileSize;
|
||||
json['exception'] = exception?.toJson();
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Copy with optional replacements. [exception] is always copied
|
||||
TaskRecord copyWith(
|
||||
{Task? task,
|
||||
TaskStatus? status,
|
||||
double? progress,
|
||||
int? expectedFileSize}) =>
|
||||
TaskRecord(
|
||||
task ?? this.task,
|
||||
status ?? this.status,
|
||||
progress ?? this.progress,
|
||||
expectedFileSize ?? this.expectedFileSize,
|
||||
exception);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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<void> 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);
|
||||
}
|
||||
|
|
@ -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<void> doDownloadTask(
|
||||
DownloadTask task,
|
||||
String filePath,
|
||||
ResumeData? resumeData,
|
||||
bool isResume,
|
||||
Duration requestTimeout,
|
||||
SendPort sendPort) async {
|
||||
// use downloadTask from here on as a 'global' variable in this isolate,
|
||||
// as we may change the filename of the task
|
||||
downloadTask = task;
|
||||
// tempFilePath is taken from [resumeDataString] if this is a resuming task.
|
||||
// Otherwise, it is a generated full path to the temp directory
|
||||
final tempFilePath = isResume && resumeData != null
|
||||
? resumeData.tempFilepath
|
||||
: p.join((await getTemporaryDirectory()).path,
|
||||
'com.bbflight.background_downloader${Random().nextInt(1 << 32).toString()}');
|
||||
final requiredStartByte =
|
||||
resumeData?.requiredStartByte ?? 0; // start for resume
|
||||
final eTag = resumeData?.eTag;
|
||||
isResume = isResume &&
|
||||
await determineIfResumeIsPossible(tempFilePath, requiredStartByte);
|
||||
final client = 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<bool> determineIfResumeIsPossible(
|
||||
String tempFilePath, int requiredStartByte) async {
|
||||
if (File(tempFilePath).existsSync()) {
|
||||
if (await File(tempFilePath).length() == requiredStartByte) {
|
||||
return true;
|
||||
} else {
|
||||
log.fine('Partially downloaded file is corrupted, resume not possible');
|
||||
}
|
||||
} else {
|
||||
log.fine('Partially downloaded file not available, resume not possible');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Process response with valid response code
|
||||
///
|
||||
/// Performs the actual bytes transfer from response to a temp file,
|
||||
/// and handles the result of the transfer:
|
||||
/// - .complete -> copy temp to final file location
|
||||
/// - .failed -> delete temp file
|
||||
/// - .paused -> post resume information
|
||||
Future<TaskStatus> processOkDownloadResponse(
|
||||
String filePath,
|
||||
String tempFilePath,
|
||||
bool serverAcceptsRanges,
|
||||
bool taskCanResume,
|
||||
bool isResume,
|
||||
Duration requestTimeout,
|
||||
http.StreamedResponse response,
|
||||
SendPort sendPort) async {
|
||||
// contentLength is extracted from response header, and if not available
|
||||
// we attempt to extract from [Task.headers], allowing developer to
|
||||
// set the content length if already known
|
||||
final contentLength = getContentLength(response.headers, downloadTask);
|
||||
isResume = isResume && response.statusCode == 206;
|
||||
if (isResume && !await prepareResume(response, tempFilePath)) {
|
||||
deleteTempFile(tempFilePath);
|
||||
return TaskStatus.failed;
|
||||
}
|
||||
var resultStatus = TaskStatus.failed;
|
||||
IOSink? outStream;
|
||||
try {
|
||||
// do the actual download
|
||||
outStream = File(tempFilePath)
|
||||
.openWrite(mode: isResume ? FileMode.append : FileMode.write);
|
||||
final transferBytesResult = await transferBytes(response.stream, outStream,
|
||||
contentLength, downloadTask, sendPort, requestTimeout);
|
||||
switch (transferBytesResult) {
|
||||
case TaskStatus.complete:
|
||||
// copy file to destination, creating dirs if needed
|
||||
await outStream.flush();
|
||||
final dirPath = p.dirname(filePath);
|
||||
Directory(dirPath).createSync(recursive: true);
|
||||
File(tempFilePath).copySync(filePath);
|
||||
resultStatus = TaskStatus.complete;
|
||||
|
||||
case TaskStatus.canceled:
|
||||
deleteTempFile(tempFilePath);
|
||||
resultStatus = TaskStatus.canceled;
|
||||
|
||||
case TaskStatus.paused:
|
||||
if (taskCanResume) {
|
||||
sendPort.send(
|
||||
('resumeData', tempFilePath, bytesTotal + startByte, eTagHeader));
|
||||
resultStatus = TaskStatus.paused;
|
||||
} else {
|
||||
taskException =
|
||||
TaskResumeException('Task was paused but cannot resume');
|
||||
resultStatus = TaskStatus.failed;
|
||||
}
|
||||
|
||||
case TaskStatus.failed:
|
||||
break;
|
||||
|
||||
default:
|
||||
throw ArgumentError('Cannot process $transferBytesResult');
|
||||
}
|
||||
} catch (e) {
|
||||
logError(downloadTask, e.toString());
|
||||
setTaskError(e);
|
||||
} finally {
|
||||
try {
|
||||
await outStream?.close();
|
||||
if (resultStatus == TaskStatus.failed &&
|
||||
serverAcceptsRanges &&
|
||||
bytesTotal + startByte > 1 << 20) {
|
||||
// send ResumeData to allow resume after fail
|
||||
sendPort.send(
|
||||
('resumeData', tempFilePath, bytesTotal + startByte, eTagHeader));
|
||||
} else if (resultStatus != TaskStatus.paused) {
|
||||
File(tempFilePath).deleteSync();
|
||||
}
|
||||
} catch (e) {
|
||||
logError(downloadTask, 'Could not delete temp file $tempFilePath');
|
||||
}
|
||||
}
|
||||
return resultStatus;
|
||||
}
|
||||
|
||||
/// Prepare for resume if possible
|
||||
///
|
||||
/// Returns true if task can continue, false if task failed.
|
||||
/// Extracts and parses Range headers, and truncates temp file
|
||||
Future<bool> prepareResume(
|
||||
http.StreamedResponse response, String tempFilePath) async {
|
||||
final range = response.headers['content-range'];
|
||||
if (range == null) {
|
||||
log.fine('Could not process partial response Content-Range');
|
||||
taskException =
|
||||
TaskResumeException('Could not process partial response Content-Range');
|
||||
return false;
|
||||
}
|
||||
final contentRangeRegEx = RegExp(r"(\d+)-(\d+)/(\d+)");
|
||||
final matchResult = contentRangeRegEx.firstMatch(range);
|
||||
if (matchResult == null) {
|
||||
log.fine('Could not process partial response Content-Range $range');
|
||||
taskException = TaskResumeException('Could not process '
|
||||
'partial response Content-Range $range');
|
||||
return false;
|
||||
}
|
||||
final start = int.parse(matchResult.group(1) ?? '0');
|
||||
final end = int.parse(matchResult.group(2) ?? '0');
|
||||
final total = int.parse(matchResult.group(3) ?? '0');
|
||||
final tempFile = File(tempFilePath);
|
||||
final tempFileLength = await tempFile.length();
|
||||
log.finest(
|
||||
'Resume start=$start, end=$end of total=$total bytes, tempFile = $tempFileLength bytes');
|
||||
startByte = start - taskRangeStartByte; // relative to start of range
|
||||
if (startByte > tempFileLength) {
|
||||
log.fine('Offered range not feasible: $range with startByte $startByte');
|
||||
taskException = TaskResumeException(
|
||||
'Offered range not feasible: $range with startByte $startByte');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
final file = await tempFile.open(mode: FileMode.writeOnlyAppend);
|
||||
await file.truncate(startByte);
|
||||
file.close();
|
||||
} on FileSystemException {
|
||||
log.fine('Could not truncate temp file');
|
||||
taskException = TaskResumeException('Could not truncate temp file');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Delete the temporary file
|
||||
void deleteTempFile(String tempFilePath) async {
|
||||
try {
|
||||
File(tempFilePath).deleteSync();
|
||||
} on FileSystemException {
|
||||
log.fine('Could not delete temp file $tempFilePath');
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract content type from [headers] and set [mimeType] and [charSet]
|
||||
void extractContentType(Map<String, String> headers) {
|
||||
final contentType = headers['content-type'];
|
||||
if (contentType != null) {
|
||||
final regEx = RegExp(r'(.*);\s*charset\s*=(.*)');
|
||||
final match = regEx.firstMatch(contentType);
|
||||
if (match != null) {
|
||||
mimeType = match.group(1);
|
||||
charSet = match.group(2);
|
||||
} else {
|
||||
mimeType = contentType;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Task>();
|
||||
final _running = Queue<Task>(); // subset that is running
|
||||
final _resume = <Task>{};
|
||||
final _isolateSendPorts =
|
||||
<Task, SendPort?>{}; // 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 = <String, dynamic>{}; // 'address' and 'port'
|
||||
static var _bypassTLSCertificateValidation = false;
|
||||
|
||||
factory DownloaderHttpClient() => _singleton;
|
||||
|
||||
DownloaderHttpClient._internal();
|
||||
|
||||
@override
|
||||
Future<bool> enqueue(Task task) async {
|
||||
try {
|
||||
Uri.decodeFull(task.url);
|
||||
} catch (e) {
|
||||
_log.fine('Invalid url: ${task.url} error: $e');
|
||||
return false;
|
||||
}
|
||||
super.enqueue(task);
|
||||
_queue.add(task);
|
||||
processStatusUpdate(TaskStatusUpdate(task, TaskStatus.enqueued));
|
||||
_advanceQueue();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Advance the queue if it's not empty and there is room in the run queue
|
||||
void _advanceQueue() {
|
||||
while (_running.length < maxConcurrent && _queue.isNotEmpty) {
|
||||
final task = _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 = <Task>[];
|
||||
while (_queue.isNotEmpty) {
|
||||
final task = _queue.removeFirst();
|
||||
if (_numActiveWithHostname(task.hostName) < maxConcurrentByHost &&
|
||||
_numActiveWithGroup(task.group) < maxConcurrentByGroup) {
|
||||
_queue.addAll(tasksThatHaveToWait); // put back in queue
|
||||
return task;
|
||||
}
|
||||
tasksThatHaveToWait.add(task);
|
||||
}
|
||||
_queue.addAll(tasksThatHaveToWait); // put back in queue
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Returns number of tasks active with this [hostname]
|
||||
int _numActiveWithHostname(String hostname) => _running.fold(
|
||||
0,
|
||||
(previousValue, task) =>
|
||||
task.hostName == hostname ? previousValue + 1 : previousValue);
|
||||
|
||||
/// Returns number of tasks active with this [group]
|
||||
int _numActiveWithGroup(String group) => _running.fold(
|
||||
0,
|
||||
(previousValue, task) =>
|
||||
task.group == group ? previousValue + 1 : previousValue);
|
||||
|
||||
/// Execute this task
|
||||
///
|
||||
/// The task runs on an Isolate, which is sent the task information and
|
||||
/// which will emit status and progress updates. These updates will be
|
||||
/// 'forwarded' to the [backgroundChannel] and processed by the
|
||||
/// [FileDownloader]
|
||||
Future<void> _executeTask(Task task) async {
|
||||
final resumeData = await getResumeData(task.taskId);
|
||||
if (resumeData != null) {
|
||||
await removeResumeData(task.taskId);
|
||||
}
|
||||
final isResume = _resume.remove(task) && resumeData != null;
|
||||
final filePath = await task.filePath(); // "" for MultiUploadTask
|
||||
// spawn an isolate to do the task
|
||||
final receivePort = ReceivePort();
|
||||
final errorPort = ReceivePort();
|
||||
errorPort.listen((message) {
|
||||
final exceptionDescription = (message as List).first as String;
|
||||
final stackTrace = message.last;
|
||||
logError(task, exceptionDescription);
|
||||
log.fine('Stack trace: $stackTrace');
|
||||
processStatusUpdate(TaskStatusUpdate(
|
||||
task, TaskStatus.failed, TaskException(exceptionDescription)));
|
||||
receivePort.close(); // also ends listener at the end
|
||||
});
|
||||
RootIsolateToken? rootIsolateToken = RootIsolateToken.instance;
|
||||
if (rootIsolateToken == null) {
|
||||
processStatusUpdate(TaskStatusUpdate(task, TaskStatus.failed,
|
||||
TaskException('Could not obtain rootIsolateToken')));
|
||||
return;
|
||||
}
|
||||
log.finer('${isResume ? "Resuming" : "Starting"} taskId ${task.taskId}');
|
||||
await Isolate.spawn(doTask, (rootIsolateToken, receivePort.sendPort),
|
||||
onError: errorPort.sendPort);
|
||||
final messagesFromIsolate = StreamQueue<dynamic>(receivePort);
|
||||
final sendPort = await messagesFromIsolate.next as SendPort;
|
||||
sendPort.send((
|
||||
task,
|
||||
filePath,
|
||||
resumeData,
|
||||
isResume,
|
||||
requestTimeout,
|
||||
proxy,
|
||||
bypassTLSCertificateValidation
|
||||
));
|
||||
if (_isolateSendPorts.keys.contains(task)) {
|
||||
// if already registered with null value, cancel immediately
|
||||
sendPort.send('cancel');
|
||||
}
|
||||
// store the isolate's sendPort so we can send it messages for
|
||||
// cancellation, and for managing parallel downloads
|
||||
_isolateSendPorts[task] = sendPort;
|
||||
// listen for messages sent back from the isolate, until 'done'
|
||||
// note that the task sent by the isolate may have changed. Therefore, we
|
||||
// use updatedTask instead of task from here on
|
||||
while (await messagesFromIsolate.hasNext) {
|
||||
final message = await messagesFromIsolate.next;
|
||||
switch (message) {
|
||||
case 'done':
|
||||
receivePort.close();
|
||||
|
||||
case (
|
||||
'statusUpdate',
|
||||
Task updatedTask,
|
||||
TaskStatus status,
|
||||
TaskException? exception,
|
||||
String? responseBody,
|
||||
Map<String, String>? 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<String> taskIds):
|
||||
await FileDownloader().cancelTasksWithIds(taskIds);
|
||||
|
||||
// from [ParallelDownloadTask]
|
||||
case ('pauseTasks', List<DownloadTask> tasks):
|
||||
for (final chunkTask in tasks) {
|
||||
await FileDownloader().pause(chunkTask);
|
||||
}
|
||||
|
||||
case ('log', String logMessage):
|
||||
_log.finest(logMessage);
|
||||
|
||||
default:
|
||||
_log.warning('Received message with unknown type '
|
||||
'$message from Isolate');
|
||||
}
|
||||
}
|
||||
errorPort.close();
|
||||
_isolateSendPorts.remove(task);
|
||||
}
|
||||
|
||||
// intercept the status and progress updates for tasks that are 'chunks', i.e.
|
||||
// part of a [ParallelDownloadTask]. Updates for these tasks are sent to the
|
||||
// isolate running the [ParallelDownloadTask] instead
|
||||
|
||||
@override
|
||||
void processStatusUpdate(TaskStatusUpdate update) {
|
||||
// Regular update if task's group is not chunkGroup
|
||||
if (update.task.group != FileDownloader.chunkGroup) {
|
||||
return super.processStatusUpdate(update);
|
||||
}
|
||||
// If chunkGroup, send update to task's parent isolate.
|
||||
// The task's metadata contains taskId of parent
|
||||
_parallelTaskSendPort(Chunk.getParentTaskId(update.task))?.send(update);
|
||||
}
|
||||
|
||||
@override
|
||||
void processProgressUpdate(TaskProgressUpdate update) {
|
||||
// Regular update if task's group is not chunkGroup
|
||||
if (update.task.group != FileDownloader.chunkGroup) {
|
||||
return super.processProgressUpdate(update);
|
||||
}
|
||||
// If chunkGroup, send update to task's parent isolate.
|
||||
// The task's metadata contains taskId of parent
|
||||
_parallelTaskSendPort(Chunk.getParentTaskId(update.task))?.send(update);
|
||||
}
|
||||
|
||||
/// Return the [SendPort] for the [ParallelDownloadTask] represented by [taskId]
|
||||
/// or null if not a [ParallelDownloadTask] or not found
|
||||
SendPort? _parallelTaskSendPort(String taskId) => _isolateSendPorts.entries
|
||||
.firstWhereOrNull((entry) =>
|
||||
entry.key is ParallelDownloadTask && entry.key.taskId == taskId)
|
||||
?.value;
|
||||
|
||||
@override
|
||||
Future<int> reset(String group) async {
|
||||
final retryAndPausedTaskCount = await super.reset(group);
|
||||
final inQueueIds = _queue.unorderedElements
|
||||
.where((task) => task.group == group)
|
||||
.map((task) => task.taskId);
|
||||
final runningIds = _running
|
||||
.where((task) => task.group == group)
|
||||
.map((task) => task.taskId);
|
||||
final taskIds = [...inQueueIds, ...runningIds];
|
||||
if (taskIds.isNotEmpty) {
|
||||
await cancelTasksWithIds(taskIds);
|
||||
}
|
||||
return retryAndPausedTaskCount + taskIds.length;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Task>> allTasks(
|
||||
String group, bool includeTasksWaitingToRetry) async {
|
||||
final retryAndPausedTasks =
|
||||
await super.allTasks(group, includeTasksWaitingToRetry);
|
||||
final inQueue =
|
||||
_queue.unorderedElements.where((task) => task.group == group);
|
||||
final running = _running.where((task) => task.group == group);
|
||||
return [...retryAndPausedTasks, ...inQueue, ...running];
|
||||
}
|
||||
|
||||
/// Cancels ongoing platform tasks whose taskId is in the list provided
|
||||
///
|
||||
/// Returns true if all cancellations were successful
|
||||
@override
|
||||
Future<bool> cancelPlatformTasksWithIds(List<String> taskIds) async {
|
||||
final inQueue = _queue.unorderedElements
|
||||
.where((task) => taskIds.contains(task.taskId))
|
||||
.toList(growable: false);
|
||||
for (final task in inQueue) {
|
||||
processStatusUpdate(TaskStatusUpdate(task, TaskStatus.canceled));
|
||||
_remove(task);
|
||||
}
|
||||
final running = _running.where((task) => taskIds.contains(task.taskId));
|
||||
for (final task in running) {
|
||||
final sendPort = _isolateSendPorts[task];
|
||||
if (sendPort != null) {
|
||||
sendPort.send('cancel');
|
||||
_isolateSendPorts.remove(task);
|
||||
} else {
|
||||
// register task for cancellation even if sendPort does not yet exist:
|
||||
// this will lead to immediate cancellation when the Isolate starts
|
||||
_isolateSendPorts[task] = null;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Task?> taskForId(String taskId) async {
|
||||
var task = await super.taskForId(taskId);
|
||||
if (task != null) {
|
||||
return task;
|
||||
}
|
||||
try {
|
||||
return _running.where((task) => task.taskId == taskId).first;
|
||||
} on StateError {
|
||||
try {
|
||||
return _queue.unorderedElements
|
||||
.where((task) => task.taskId == taskId)
|
||||
.first;
|
||||
} on StateError {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> pause(Task task) async {
|
||||
final sendPort = _isolateSendPorts[task];
|
||||
if (sendPort != null) {
|
||||
sendPort.send('pause');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> resume(Task task) async {
|
||||
if (await super.resume(task)) {
|
||||
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<Map<String, String>> popUndeliveredData(Undelivered dataType) =>
|
||||
Future.value({});
|
||||
|
||||
@override
|
||||
Future<String?> moveToSharedStorage(String filePath,
|
||||
SharedStorage destination, String directory, String? mimeType) async {
|
||||
final destDirectoryPath =
|
||||
await getDestinationDirectoryPath(destination, directory);
|
||||
if (destDirectoryPath == null) {
|
||||
return null;
|
||||
}
|
||||
if (!await Directory(destDirectoryPath).exists()) {
|
||||
await Directory(destDirectoryPath).create(recursive: true);
|
||||
}
|
||||
final fileName = path.basename(filePath);
|
||||
final destFilePath = path.join(destDirectoryPath, fileName);
|
||||
try {
|
||||
await File(filePath).rename(destFilePath);
|
||||
} on FileSystemException catch (e) {
|
||||
_log.warning('Error moving $filePath to shared storage: $e');
|
||||
return null;
|
||||
}
|
||||
return destFilePath;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> pathInSharedStorage(
|
||||
String filePath, SharedStorage destination, String directory) async {
|
||||
final destDirectoryPath =
|
||||
await getDestinationDirectoryPath(destination, directory);
|
||||
if (destDirectoryPath == null) {
|
||||
return null;
|
||||
}
|
||||
final fileName = path.basename(filePath);
|
||||
return path.join(destDirectoryPath, fileName);
|
||||
}
|
||||
|
||||
/// Returns the path of the destination directory in shared storage, or null
|
||||
///
|
||||
/// Only the .Downloads directory is supported on desktop.
|
||||
/// The [directory] is appended to the base Downloads directory.
|
||||
/// The directory at the returned path is not guaranteed to exist.
|
||||
Future<String?> getDestinationDirectoryPath(
|
||||
SharedStorage destination, String directory) async {
|
||||
if (destination != SharedStorage.downloads) {
|
||||
_log.finer('Desktop only supports .downloads destination');
|
||||
return null;
|
||||
}
|
||||
final downloadsDirectory = await getDownloadsDirectory();
|
||||
if (downloadsDirectory == null) {
|
||||
_log.warning('Could not obtain downloads directory');
|
||||
return null;
|
||||
}
|
||||
// remove leading and trailing slashes from [directory]
|
||||
var cleanDirectory = directory.replaceAll(RegExp(r'^/+'), '');
|
||||
cleanDirectory = cleanDirectory.replaceAll(RegExp(r'/$'), '');
|
||||
return cleanDirectory.isEmpty
|
||||
? downloadsDirectory.path
|
||||
: path.join(downloadsDirectory.path, cleanDirectory);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> openFile(Task? task, String? filePath, String? mimeType) async {
|
||||
final executable = Platform.isLinux
|
||||
? 'xdg-open'
|
||||
: Platform.isMacOS
|
||||
? 'open'
|
||||
: 'start';
|
||||
filePath ??= await task!.filePath();
|
||||
if (!await File(filePath).exists()) {
|
||||
_log.fine('File to open does not exist: $filePath');
|
||||
return false;
|
||||
}
|
||||
final result = await Process.run(executable, [filePath], runInShell: true);
|
||||
if (result.exitCode != 0) {
|
||||
_log.fine(
|
||||
'openFile command $executable returned exit code ${result.exitCode}');
|
||||
}
|
||||
return result.exitCode == 0;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Duration> getTaskTimeout() => Future.value(const Duration(days: 1));
|
||||
|
||||
@override
|
||||
Future<void> setForceFailPostOnBackgroundChannel(bool value) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> testSuggestedFilename(
|
||||
DownloadTask task, String contentDisposition) async {
|
||||
final h = contentDisposition.isNotEmpty
|
||||
? {'Content-disposition': contentDisposition}
|
||||
: <String, String>{};
|
||||
final t = await taskWithSuggestedFilename(task, h, false);
|
||||
return t.filename;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic platformConfig(
|
||||
{dynamic globalConfig,
|
||||
dynamic androidConfig,
|
||||
dynamic iOSConfig,
|
||||
dynamic desktopConfig}) =>
|
||||
desktopConfig;
|
||||
|
||||
@override
|
||||
Future<(String, String)> configureItem((String, dynamic) configItem) async {
|
||||
switch (configItem) {
|
||||
case (Config.requestTimeout, Duration? duration):
|
||||
requestTimeout = duration;
|
||||
|
||||
case (Config.proxy, (String address, int port)):
|
||||
proxy = {'address': address, 'port': port};
|
||||
|
||||
case (Config.proxy, false):
|
||||
proxy = {};
|
||||
|
||||
case (Config.bypassTLSCertificateValidation, bool bypass):
|
||||
bypassTLSCertificateValidation = bypass;
|
||||
|
||||
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<String, dynamic> value) {
|
||||
_proxy = value;
|
||||
_recreateClient();
|
||||
}
|
||||
|
||||
static Map<String, dynamic> get proxy => _proxy;
|
||||
|
||||
/// Set or resets bypass for TLS certificate validation
|
||||
static set bypassTLSCertificateValidation(bool value) {
|
||||
_bypassTLSCertificateValidation = value;
|
||||
_recreateClient();
|
||||
}
|
||||
|
||||
static bool get bypassTLSCertificateValidation =>
|
||||
_bypassTLSCertificateValidation;
|
||||
|
||||
/// Set the HTTP Client to use, with the given parameters
|
||||
///
|
||||
/// This is a convenience method, bundling the [requestTimeout],
|
||||
/// [proxy] and [bypassTLSCertificateValidation]
|
||||
static void setHttpClient(Duration? requestTimeout,
|
||||
Map<String, dynamic> proxy, bool bypassTLSCertificateValidation) {
|
||||
_requestTimeout = requestTimeout;
|
||||
_proxy = proxy;
|
||||
_bypassTLSCertificateValidation = bypassTLSCertificateValidation;
|
||||
_recreateClient();
|
||||
}
|
||||
|
||||
/// Recreates the [httpClient] used for Requests and isolate downloads/uploads
|
||||
static _recreateClient() 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, String>? responseHeaders;
|
||||
int? responseStatusCode;
|
||||
String? mimeType; // derived from Content-Type header
|
||||
String? charSet; // derived from Content-Type header
|
||||
|
||||
// logging from isolate is always 'FINEST', as it is sent to
|
||||
// the [DesktopDownloader] for processing
|
||||
final log = Logger('FileDownloader');
|
||||
|
||||
/// Do the task, sending messages back to the main isolate via [sendPort]
|
||||
///
|
||||
/// The first message sent back is a [ReceivePort] that is the command port
|
||||
/// for the isolate. The first command must be the arguments: task and filePath.
|
||||
Future<void> doTask((RootIsolateToken, SendPort) isolateArguments) async {
|
||||
final (rootIsolateToken, sendPort) = isolateArguments;
|
||||
BackgroundIsolateBinaryMessenger.ensureInitialized(rootIsolateToken);
|
||||
final receivePort = ReceivePort();
|
||||
// send the receive port back to the main Isolate
|
||||
sendPort.send(receivePort.sendPort);
|
||||
final messagesToIsolate = StreamQueue<dynamic>(receivePort);
|
||||
// get the arguments list and parse each argument
|
||||
final (
|
||||
Task task,
|
||||
String filePath,
|
||||
ResumeData? resumeData,
|
||||
bool isResume,
|
||||
Duration? requestTimeout,
|
||||
Map<String, dynamic> 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<void> listenToIncomingMessages(
|
||||
Task task, StreamQueue messagesToIsolate, SendPort sendPort) async {
|
||||
while (await messagesToIsolate.hasNext) {
|
||||
final message = await messagesToIsolate.next;
|
||||
switch (message) {
|
||||
case 'cancel':
|
||||
isCanceled = true; // checked in loop elsewhere
|
||||
if (task is ParallelDownloadTask) {
|
||||
cancelParallelDownloadTask(task, sendPort);
|
||||
}
|
||||
|
||||
case 'pause':
|
||||
isPaused = true; // checked in loop elsewhere
|
||||
if (task is ParallelDownloadTask) {
|
||||
pauseParallelDownloadTask(task, sendPort);
|
||||
}
|
||||
|
||||
// Status and progress updates are incoming from chunk tasks, part of a
|
||||
// [ParallelDownloadTask]. We update the chunk status/progress and
|
||||
// determine the aggregate status/progress for the parent task. If changed,
|
||||
// we process that update for the parent task as we would for a regular
|
||||
// [DownloadTask].
|
||||
// Note that [task] refers to the parent task, whereas [update.task] refers
|
||||
// to the chunk (child) task
|
||||
case TaskStatusUpdate update:
|
||||
await chunkStatusUpdate(task, update, sendPort);
|
||||
|
||||
case TaskProgressUpdate update:
|
||||
chunkProgressUpdate(task, update, sendPort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Transfer all bytes from [inStream] to [outStream], expecting [contentLength]
|
||||
/// total bytes
|
||||
///
|
||||
/// Sends updates via the [sendPort] and can be commanded to cancel/pause via
|
||||
/// the [messagesToIsolate] queue
|
||||
///
|
||||
/// Returns a [TaskStatus] and will throw any exception generated within
|
||||
///
|
||||
/// Note: does not flush or close any streams
|
||||
Future<TaskStatus> transferBytes(
|
||||
Stream<List<int>> inStream,
|
||||
StreamSink<List<int>> outStream,
|
||||
int contentLength,
|
||||
Task task,
|
||||
SendPort sendPort,
|
||||
[Duration requestTimeout = const Duration(seconds: 60)]) async {
|
||||
if (contentLength == 0) {
|
||||
contentLength = -1;
|
||||
}
|
||||
var resultStatus = TaskStatus.complete;
|
||||
try {
|
||||
await outStream
|
||||
.addStream(inStream.timeout(requestTimeout, onTimeout: (sink) {
|
||||
taskException = TaskConnectionException('Connection timed out');
|
||||
resultStatus = TaskStatus.failed;
|
||||
sink.close(); // ends the stream
|
||||
}).map((bytes) {
|
||||
if (isCanceled) {
|
||||
resultStatus = TaskStatus.canceled;
|
||||
throw StateError('Canceled');
|
||||
}
|
||||
if (isPaused) {
|
||||
resultStatus = TaskStatus.paused;
|
||||
throw StateError('Paused');
|
||||
}
|
||||
bytesTotal += bytes.length;
|
||||
final progress = min(
|
||||
(bytesTotal + startByte).toDouble() / (contentLength + startByte),
|
||||
0.999);
|
||||
final now = DateTime.now();
|
||||
if (contentLength > 0 && shouldSendProgressUpdate(progress, now)) {
|
||||
processProgressUpdateInIsolate(
|
||||
task, progress, sendPort, contentLength + startByte);
|
||||
lastProgressUpdate = progress;
|
||||
nextProgressUpdateTime = now.add(const Duration(milliseconds: 500));
|
||||
}
|
||||
return bytes;
|
||||
}));
|
||||
} catch (e) {
|
||||
if (resultStatus == TaskStatus.complete) {
|
||||
// this was an unintentional error thrown within the stream processing
|
||||
logError(task, e.toString());
|
||||
setTaskError(e);
|
||||
resultStatus = TaskStatus.failed;
|
||||
}
|
||||
}
|
||||
return resultStatus;
|
||||
}
|
||||
|
||||
/// Processes a change in status for the [task]
|
||||
///
|
||||
/// Sends status update via the [sendPort], if requested
|
||||
/// If the task is finished, processes a final progressUpdate update
|
||||
void processStatusUpdateInIsolate(
|
||||
Task task, TaskStatus status, SendPort sendPort) {
|
||||
final retryNeeded = status == TaskStatus.failed && task.retriesRemaining > 0;
|
||||
// if task is in final state, process a final progressUpdate
|
||||
// A 'failed' progress update is only provided if
|
||||
// a retry is not needed: if it is needed, a `waitingToRetry` progress update
|
||||
// will be generated in the FileDownloader
|
||||
switch (status) {
|
||||
case TaskStatus.complete:
|
||||
processProgressUpdateInIsolate(task, progressComplete, sendPort);
|
||||
|
||||
case TaskStatus.failed when !retryNeeded:
|
||||
processProgressUpdateInIsolate(task, progressFailed, sendPort);
|
||||
|
||||
case TaskStatus.canceled:
|
||||
processProgressUpdateInIsolate(task, progressCanceled, sendPort);
|
||||
|
||||
case TaskStatus.notFound:
|
||||
processProgressUpdateInIsolate(task, progressNotFound, sendPort);
|
||||
|
||||
case TaskStatus.paused:
|
||||
processProgressUpdateInIsolate(task, progressPaused, sendPort);
|
||||
|
||||
default:
|
||||
{}
|
||||
}
|
||||
// Post update if task expects one, or if failed and retry is needed
|
||||
if (task.providesStatusUpdates || retryNeeded) {
|
||||
sendPort.send((
|
||||
'statusUpdate',
|
||||
task,
|
||||
status,
|
||||
status == TaskStatus.failed
|
||||
? taskException ?? TaskException('None')
|
||||
: null,
|
||||
status.isFinalState ? responseBody : null,
|
||||
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<String?> responseContent(http.StreamedResponse response) {
|
||||
try {
|
||||
return response.stream.bytesToString();
|
||||
} catch (e) {
|
||||
log.fine(
|
||||
'Could not read response content from httpResponseCode ${response.statusCode}: $e');
|
||||
return Future.value(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if [currentProgress] > [lastProgressUpdate] + threshold and
|
||||
/// [now] > [nextProgressUpdateTime]
|
||||
bool shouldSendProgressUpdate(double currentProgress, DateTime now) {
|
||||
return currentProgress - lastProgressUpdate > 0.02 &&
|
||||
now.isAfter(nextProgressUpdateTime);
|
||||
}
|
||||
|
|
@ -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 = <Chunk>[]; // chunks associated with this download
|
||||
var lastTaskStatus = TaskStatus.running;
|
||||
Completer<TaskStatusUpdate> parallelTaskStatusUpdateCompleter = Completer();
|
||||
var parallelDownloadContentLength = -1;
|
||||
|
||||
/// Execute the parallel download task
|
||||
///
|
||||
/// Sends updates via the [sendPort] and can be commanded to cancel/pause via
|
||||
/// the [messagesToIsolate] queue.
|
||||
///
|
||||
/// If [isResume] is false, we create [Chunk]s and enqueue each of its tasks
|
||||
/// by sending a message to the [DesktopDownloader], then wait for the
|
||||
/// completion of [parallelTaskStatusUpdateCompleter]
|
||||
///
|
||||
/// If [isResume] is true, [resumeData] contains the json encoded [chunks] list,
|
||||
/// and the associated chunk [DownloadTask] tasks will be started by the
|
||||
/// [DesktopDownloader], so we just wait for completion of
|
||||
/// [parallelTaskStatusUpdateCompleter]
|
||||
///
|
||||
/// Incoming messages from the [DesktopDownloader] are received in the
|
||||
/// [listenToIncomingMessages] function
|
||||
Future<void> doParallelDownloadTask(
|
||||
ParallelDownloadTask task,
|
||||
String filePath,
|
||||
ResumeData? resumeData,
|
||||
bool isResume,
|
||||
Duration requestTimeout,
|
||||
SendPort sendPort) async {
|
||||
parentTask = task;
|
||||
if (!isResume) {
|
||||
// start the download by creating [Chunk]s and enqueuing chunk tasks
|
||||
final response = await (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<void> chunkStatusUpdate(
|
||||
Task task, TaskStatusUpdate update, SendPort sendPort) async {
|
||||
final chunkTask = update.task;
|
||||
// first check for fail -> retry
|
||||
if (update.status == TaskStatus.failed && chunkTask.retriesRemaining > 0) {
|
||||
chunkTask.decreaseRetriesRemaining();
|
||||
final waitTime = Duration(
|
||||
seconds:
|
||||
2 << min(chunkTask.retries - chunkTask.retriesRemaining - 1, 8));
|
||||
log.finer(
|
||||
'Chunk task with taskId ${chunkTask.taskId} failed, waiting ${waitTime.inSeconds}'
|
||||
' seconds before retrying. ${chunkTask.retriesRemaining}'
|
||||
' retries remaining');
|
||||
Future.delayed(waitTime, () async {
|
||||
// after delay, resume or enqueue task again if it's still waiting
|
||||
sendPort.send(('enqueueChild', chunkTask));
|
||||
});
|
||||
} else {
|
||||
// no retry
|
||||
final newStatusUpdate = updateChunkStatus(update);
|
||||
switch (newStatusUpdate) {
|
||||
case TaskStatus.complete:
|
||||
final result = await stitchChunks();
|
||||
parallelTaskStatusUpdateCompleter.complete(TaskStatusUpdate(task,
|
||||
result, null, responseBody, responseHeaders, responseStatusCode));
|
||||
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<TaskStatus> stitchChunks() async {
|
||||
IOSink? outStream;
|
||||
StreamSubscription? subscription;
|
||||
try {
|
||||
final outFile = File(await parentTask.filePath());
|
||||
if (await outFile.exists()) {
|
||||
await outFile.delete();
|
||||
}
|
||||
outStream = outFile.openWrite();
|
||||
for (final chunk in chunks.sorted((a, b) => a.fromByte - b.fromByte)) {
|
||||
final inFile = File(await chunk.task.filePath());
|
||||
if (!await inFile.exists()) {
|
||||
throw const FileSystemException('Missing chunk file');
|
||||
}
|
||||
final inStream = inFile.openRead();
|
||||
final doneCompleter = Completer<bool>();
|
||||
subscription = inStream.listen(
|
||||
(bytes) {
|
||||
outStream?.add(bytes);
|
||||
},
|
||||
onDone: () => doneCompleter.complete(true),
|
||||
onError: (error) {
|
||||
logError(parentTask, e.toString());
|
||||
setTaskError(error);
|
||||
doneCompleter.complete(false);
|
||||
});
|
||||
final success = await doneCompleter.future;
|
||||
if (!success) {
|
||||
return TaskStatus.failed;
|
||||
}
|
||||
subscription.cancel();
|
||||
await inFile.delete();
|
||||
}
|
||||
await outStream.flush();
|
||||
} catch (e) {
|
||||
logError(parentTask, e.toString());
|
||||
setTaskError(e);
|
||||
return TaskStatus.failed;
|
||||
} finally {
|
||||
await outStream?.close();
|
||||
subscription?.cancel();
|
||||
for (final chunk in chunks) {
|
||||
try {
|
||||
final file = File(await chunk.task.filePath());
|
||||
await file.delete();
|
||||
} on FileSystemException {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
return TaskStatus.complete;
|
||||
}
|
||||
|
||||
/// Returns a list of chunk information for this task, and sets
|
||||
/// [parallelDownloadContentLength] to the total length of the download
|
||||
///
|
||||
/// Throws a StateError if any information is missing, which should lead
|
||||
/// to a failure of the [ParallelDownloadTask]
|
||||
List<Chunk> createChunks(
|
||||
ParallelDownloadTask task, Map<String, String> headers) {
|
||||
try {
|
||||
final numChunks = task.urls.length * task.chunks;
|
||||
final contentLength = getContentLength(headers, task);
|
||||
if (contentLength <= 0) {
|
||||
throw StateError(
|
||||
'Server does not provide content length - cannot chunk download. '
|
||||
'If you know the length, set Range or Known-Content-Length header');
|
||||
}
|
||||
parallelDownloadContentLength = contentLength;
|
||||
try {
|
||||
headers.entries.firstWhere((element) =>
|
||||
element.key.toLowerCase() == 'accept-ranges' &&
|
||||
element.value == 'bytes');
|
||||
} on StateError {
|
||||
throw StateError('Server does not accept ranges - cannot chunk download');
|
||||
}
|
||||
final chunkSize = (contentLength / numChunks).ceil();
|
||||
return [
|
||||
for (var i = 0; i < numChunks; i++)
|
||||
Chunk(
|
||||
parentTask: task,
|
||||
url: task.urls[i % task.urls.length],
|
||||
filename: Random().nextInt(1 << 32).toString(),
|
||||
fromByte: i * chunkSize,
|
||||
toByte: min(i * chunkSize + chunkSize - 1, contentLength - 1))
|
||||
];
|
||||
} on StateError {
|
||||
throw StateError(
|
||||
'Server does not provide content length - cannot chunk download. '
|
||||
'If you know the length, set Range or Known-Content-Length header');
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> doUploadTask(
|
||||
UploadTask task, String filePath, SendPort sendPort) async {
|
||||
final resultStatus = task.post == 'binary'
|
||||
? await binaryUpload(task, filePath, sendPort)
|
||||
: await multipartUpload(task, filePath, sendPort);
|
||||
processStatusUpdateInIsolate(task, resultStatus, sendPort);
|
||||
}
|
||||
|
||||
/// Do the binary upload and return the TaskStatus
|
||||
///
|
||||
/// Sends updates via the [sendPort] and can be commanded to cancel via
|
||||
/// the [messagesToIsolate] queue
|
||||
Future<TaskStatus> binaryUpload(
|
||||
UploadTask task, String filePath, SendPort sendPort) async {
|
||||
final inFile = File(filePath);
|
||||
if (!inFile.existsSync()) {
|
||||
logError(task, 'file to upload does not exist: $filePath');
|
||||
taskException =
|
||||
TaskFileSystemException('File to upload does not exist: $filePath');
|
||||
return TaskStatus.failed;
|
||||
}
|
||||
final fileSize = inFile.lengthSync();
|
||||
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<TaskStatus> 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 = <String>[];
|
||||
final contentTypeStrings = <String>[];
|
||||
final fileLengths = <int>[];
|
||||
for (final (fileField, path, mimeType) in filesData) {
|
||||
final file = File(path);
|
||||
if (!await file.exists()) {
|
||||
logError(task, 'File to upload does not exist: $path');
|
||||
taskException =
|
||||
TaskFileSystemException('File to upload does not exist: $path');
|
||||
return TaskStatus.failed;
|
||||
}
|
||||
contentDispositionStrings.add(
|
||||
'Content-Disposition: form-data; name="${browserEncode(fileField)}"; '
|
||||
'filename="${browserEncode(p.basename(file.path))}"$lineFeed',
|
||||
);
|
||||
contentTypeStrings.add('Content-Type: $mimeType$lineFeed$lineFeed');
|
||||
fileLengths.add(file.lengthSync());
|
||||
}
|
||||
final fileDataLength = contentDispositionStrings.fold<int>(
|
||||
0, (sum, string) => sum + lengthInBytes(string)) +
|
||||
contentTypeStrings.fold<int>(0, (sum, string) => sum + string.length) +
|
||||
fileLengths.fold<int>(0, (sum, length) => sum + length) +
|
||||
separator.length * contentDispositionStrings.length +
|
||||
2;
|
||||
final contentLength = lengthInBytes(fieldsString) +
|
||||
'--$boundary$lineFeed'.length +
|
||||
fileDataLength;
|
||||
var resultStatus = TaskStatus.failed;
|
||||
try {
|
||||
// setup the connection
|
||||
final client = 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;
|
||||
}
|
||||
|
|
@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> toJson() =>
|
||||
{...super.toJson(), 'httpResponseCode': httpResponseCode};
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '$exceptionType, response code $httpResponseCode: $description';
|
||||
}
|
||||
}
|
||||
|
|
@ -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<bool> get ready => _downloader.ready;
|
||||
|
||||
/// Stream of [TaskUpdate] updates for downloads that do
|
||||
/// not have a registered callback
|
||||
Stream<TaskUpdate> 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<List<(String, String)>> 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<TaskQueue> get taskQueues => _downloader.taskQueues;
|
||||
|
||||
/// Enqueue a new [Task]
|
||||
///
|
||||
/// Returns true if successfully enqueued. A new task will also generate
|
||||
/// a [TaskStatus.enqueued] update to the registered callback,
|
||||
/// if requested by its [updates] property
|
||||
///
|
||||
/// Use [enqueue] instead of the convenience functions (like
|
||||
/// [download] and [upload]) if:
|
||||
/// - your download/upload is likely to take long and may require
|
||||
/// running in the background
|
||||
/// - you want to monitor tasks centrally, via a listener
|
||||
/// - you want more detailed progress information
|
||||
/// (e.g. file size, network speed, time remaining)
|
||||
Future<bool> enqueue(Task task) => _downloader.enqueue(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<TaskStatusUpdate> 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<TaskStatusUpdate> 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<TaskStatusUpdate> 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<Batch> downloadBatch(final List<DownloadTask> 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<Batch> uploadBatch(final List<UploadTask> 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<int> 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<List<String>> 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<List<Task>> 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<bool> tasksFinished(
|
||||
{String group = defaultGroup,
|
||||
bool includeTasksWaitingToRetry = true,
|
||||
String? ignoreTaskId}) async {
|
||||
final tasksInProgress = await allTasks(
|
||||
group: group, includeTasksWaitingToRetry: includeTasksWaitingToRetry);
|
||||
if (ignoreTaskId != null) {
|
||||
tasksInProgress.removeWhere((task) => task.taskId == ignoreTaskId);
|
||||
}
|
||||
return tasksInProgress.isEmpty;
|
||||
}
|
||||
|
||||
/// Cancel all tasks matching the taskIds in the list
|
||||
///
|
||||
/// Every canceled task wil emit a [TaskStatus.canceled] update to
|
||||
/// the registered callback, if requested
|
||||
Future<bool> cancelTasksWithIds(List<String> taskIds) =>
|
||||
_downloader.cancelTasksWithIds(taskIds);
|
||||
|
||||
/// Cancel this task
|
||||
///
|
||||
/// The task will emit a [TaskStatus.canceled] update to
|
||||
/// the registered callback, if requested
|
||||
Future<bool> cancelTaskWithId(String taskId) => cancelTasksWithIds([taskId]);
|
||||
|
||||
/// Return [Task] for the given [taskId], or null
|
||||
/// if not found.
|
||||
///
|
||||
/// Only running tasks are guaranteed to be returned, but returning a task
|
||||
/// does not guarantee that the task is still running. To keep track of
|
||||
/// the status of tasks, use a [TaskStatusCallback]
|
||||
Future<Task?> taskForId(String taskId) => _downloader.taskForId(taskId);
|
||||
|
||||
/// Activate tracking for tasks in this [group]
|
||||
///
|
||||
/// All subsequent tasks in this group will be recorded in persistent storage.
|
||||
/// Use the [FileDownloader.database] to get or remove [TaskRecord] objects,
|
||||
/// which contain a [Task], its [TaskStatus] and a [double] for progress.
|
||||
///
|
||||
/// If [markDownloadedComplete] is true (default) then all tasks in the
|
||||
/// database that are marked as not yet [TaskStatus.complete] will be set to
|
||||
/// [TaskStatus.complete] if the target file for that task exists.
|
||||
/// They will also emit [TaskStatus.complete] and [progressComplete] to
|
||||
/// their registered listener or callback.
|
||||
/// This is a convenient way to capture downloads that have completed while
|
||||
/// the app was suspended: on app startup, immediately register your
|
||||
/// listener or callbacks, and call [trackTasks] for each group.
|
||||
///
|
||||
/// Returns the [FileDownloader] for easy chaining
|
||||
Future<FileDownloader> trackTasksInGroup(String group,
|
||||
{bool markDownloadedComplete = true}) async {
|
||||
await _downloader.trackTasks(group, markDownloadedComplete);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// Activate tracking for all tasks
|
||||
///
|
||||
/// All subsequent tasks will be recorded in persistent storage.
|
||||
/// Use the [FileDownloader.database] to get or remove [TaskRecord] objects,
|
||||
/// which contain a [Task], its [TaskStatus] and a [double] for progress.
|
||||
///
|
||||
/// If [markDownloadedComplete] is true (default) then all tasks in the
|
||||
/// database that are marked as not yet [TaskStatus.complete] will be set to
|
||||
/// [TaskStatus.complete] if the target file for that task exists.
|
||||
/// They will also emit [TaskStatus.complete] and [progressComplete] to
|
||||
/// their registered listener or callback.
|
||||
/// This is a convenient way to capture downloads that have completed while
|
||||
/// the app was suspended: on app startup, immediately register your
|
||||
/// listener or callbacks, and call [trackTasks].
|
||||
///
|
||||
/// Returns the [FileDownloader] for easy chaining
|
||||
Future<FileDownloader> trackTasks(
|
||||
{bool markDownloadedComplete = true}) async {
|
||||
await _downloader.trackTasks(null, markDownloadedComplete);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// Wakes up the FileDownloader from possible background state, triggering
|
||||
/// a stream of updates that may have been processed while in the background,
|
||||
/// and have not yet reached the callbacks or listener
|
||||
///
|
||||
/// Calling this method multiple times has no effect.
|
||||
Future<void> resumeFromBackground() =>
|
||||
_downloader.retrieveLocallyStoredData();
|
||||
|
||||
/// Returns true if task can be resumed on pause
|
||||
///
|
||||
/// This future only completes once the task is running and has received
|
||||
/// information from the server to determine whether resume is possible, or
|
||||
/// if the task fails and resume is possible
|
||||
Future<bool> taskCanResume(Task task) => _downloader.taskCanResume(task);
|
||||
|
||||
/// Pause the task
|
||||
///
|
||||
/// Returns true if the pause was attempted successfully. Test the task's
|
||||
/// status to see if it was executed successfully [TaskStatus.paused] or if
|
||||
/// it failed after all [TaskStatus.failed]
|
||||
///
|
||||
/// If the [Task.allowPause] field is set to false (default) or if this is
|
||||
/// a POST request, this method returns false immediately.
|
||||
Future<bool> pause(DownloadTask task) async {
|
||||
if (task.allowPause && task.post == null) {
|
||||
return _downloader.pause(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<bool> 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<bool> requireWiFi(RequireWiFi requirement,
|
||||
{final rescheduleRunningTasks = true}) =>
|
||||
_downloader.requireWiFi(requirement, rescheduleRunningTasks);
|
||||
|
||||
/// Returns the current global setting for requiring WiFi
|
||||
Future<RequireWiFi> getRequireWiFiSetting() =>
|
||||
_downloader.getRequireWiFiSetting();
|
||||
|
||||
/// Configure notification for a single task
|
||||
///
|
||||
/// The configuration determines what notifications are shown,
|
||||
/// whether a progress bar is shown (Android only), and whether tapping
|
||||
/// the 'complete' notification opens the downloaded file.
|
||||
///
|
||||
/// [running] is the notification used while the task is in progress
|
||||
/// [complete] is the notification used when the task completed
|
||||
/// [error] is the notification used when something went wrong,
|
||||
/// including 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<http.Response> 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<String?> moveToSharedStorage(
|
||||
DownloadTask task,
|
||||
SharedStorage destination, {
|
||||
String directory = '',
|
||||
String? mimeType,
|
||||
}) async =>
|
||||
moveFileToSharedStorage(await task.filePath(), destination,
|
||||
directory: directory, mimeType: mimeType);
|
||||
|
||||
/// Move the file represented by [filePath] to a shared storage
|
||||
/// [destination] and potentially a [directory] within that destination. If
|
||||
/// the [mimeType] is not provided we will attempt to derive it from the
|
||||
/// [filePath] extension
|
||||
///
|
||||
/// Returns the path to the stored file, or null if not successful
|
||||
/// NOTE: on iOS, using [destination] [SharedStorage.images] or
|
||||
/// [SharedStorage.video] adds the photo or video file to the Photos
|
||||
/// library. This requires the user to grant permission, and requires the
|
||||
/// "NSPhotoLibraryAddUsageDescription" key to be set in Info.plist. The
|
||||
/// returned value is NOT a filePath but an identifier. If the full filepath
|
||||
/// is required, follow the [moveToSharedStorage] call with a call to
|
||||
/// [pathInSharedStorage], passing the identifier obtained from the call
|
||||
/// to [moveToSharedStorage] as the filePath parameter. This requires the user to
|
||||
/// grant additional permissions, and requires the "NSPhotoLibraryUsageDescription"
|
||||
/// key to be set in Info.plist. The returned value is the actual file path
|
||||
/// of the photo or video in the Photos Library.
|
||||
///
|
||||
/// Platform-dependent, not consistent across all platforms
|
||||
Future<String?> moveFileToSharedStorage(
|
||||
String filePath,
|
||||
SharedStorage destination, {
|
||||
String directory = '',
|
||||
String? mimeType,
|
||||
}) async =>
|
||||
_downloader.moveToSharedStorage(
|
||||
filePath, destination, directory, mimeType);
|
||||
|
||||
/// Returns the filePath to the file represented by [filePath] in shared
|
||||
/// storage [destination] and potentially a [directory] within that
|
||||
/// destination.
|
||||
///
|
||||
/// Returns the path to the stored file, or null if not successful
|
||||
///
|
||||
/// See the documentation for [moveToSharedStorage] for special use case
|
||||
/// on iOS for .images and .video
|
||||
///
|
||||
/// Platform-dependent, not consistent across all platforms
|
||||
Future<String?> pathInSharedStorage(
|
||||
String filePath, SharedStorage destination,
|
||||
{String directory = ''}) async =>
|
||||
_downloader.pathInSharedStorage(filePath, destination, directory);
|
||||
|
||||
/// Open the file represented by [task] or [filePath] using the application
|
||||
/// available on the platform.
|
||||
///
|
||||
/// [mimeType] may override the mimetype derived from the file extension,
|
||||
/// though implementation depends on the platform and may not always work.
|
||||
///
|
||||
/// Returns true if an application was launched successfully
|
||||
Future<bool> openFile({Task? task, String? filePath, String? mimeType}) {
|
||||
assert(task != null || filePath != null, 'Task or filePath must be set');
|
||||
assert(!(task != null && filePath != null),
|
||||
'Either task or filePath must be set, not both');
|
||||
return _downloader.openFile(task, filePath, mimeType);
|
||||
}
|
||||
|
||||
/// Return the platform version as a String
|
||||
///
|
||||
/// On Android this is the API integer, e.g. "33"
|
||||
/// On iOS this is the iOS version, e.g. "16.1"
|
||||
/// On desktop this is a description of the OS version, not parsable
|
||||
Future<String> platformVersion() => _downloader.platformVersion();
|
||||
|
||||
/// Closes the [updates] stream and re-initializes the [StreamController]
|
||||
/// such that the stream can be listened to again
|
||||
Future<void> resetUpdates() => _downloader.resetUpdatesStreamController();
|
||||
|
||||
/// Destroy the [FileDownloader]. Subsequent use requires initialization
|
||||
void destroy() {
|
||||
_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<http.Response> _doRequest(
|
||||
(Request, Duration?, Map<String, dynamic>, 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');
|
||||
}
|
||||
|
|
@ -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<List>? _conditions;
|
||||
|
||||
static final pathSeparatorRegEx = RegExp(r'[/\\]');
|
||||
|
||||
/// The parent [CollectionRef] of this document.
|
||||
CollectionRef? get parent => _parent;
|
||||
|
||||
CollectionRef._(this._id, [this._parent, this._delegate, this._conditions]) {
|
||||
_path = _buildPath(_parent?.path, _id, _delegate?.id);
|
||||
}
|
||||
static final _cache = <String, CollectionRef>{};
|
||||
|
||||
/// Returns an instance using the default [CollectionRef].
|
||||
factory CollectionRef(
|
||||
String id, [
|
||||
CollectionRef? parent,
|
||||
DocumentRef? delegate,
|
||||
List<List>? conditions,
|
||||
]) {
|
||||
final key = _buildPath(parent?.path, id, delegate?.id);
|
||||
final collectionRef = _cache.putIfAbsent(
|
||||
key, () => CollectionRef._(id, parent, delegate, conditions));
|
||||
collectionRef._conditions = conditions;
|
||||
return collectionRef;
|
||||
}
|
||||
|
||||
static String _buildPath(String? parentPath, String path, String? docId) {
|
||||
final docPath =
|
||||
((docId != null && parentPath != null) ? '$docId.collection' : '');
|
||||
final pathSep = p.separator;
|
||||
return '${parentPath ?? ''}$docPath$pathSep$path$pathSep';
|
||||
}
|
||||
|
||||
final _utils = Utils.instance;
|
||||
|
||||
@override
|
||||
Stream<Map<String, dynamic>> get stream => _utils.stream(path, _conditions);
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>?> get() async {
|
||||
return await _utils.get(path, true, _conditions);
|
||||
}
|
||||
|
||||
@override
|
||||
DocumentRef doc([String? id]) {
|
||||
id ??= int.parse(
|
||||
'${Random().nextInt(1000000000)}${Random().nextInt(1000000000)}')
|
||||
.toRadixString(35)
|
||||
.substring(0, 9);
|
||||
return DocumentRef(id, this);
|
||||
}
|
||||
|
||||
@override
|
||||
CollectionRef where(
|
||||
field, {
|
||||
isEqualTo,
|
||||
}) {
|
||||
final conditions = <List>[];
|
||||
void addCondition(dynamic field, String operator, dynamic value) {
|
||||
List<dynamic> condition;
|
||||
|
||||
condition = <dynamic>[field, operator, value];
|
||||
conditions.add(condition);
|
||||
}
|
||||
|
||||
if (isEqualTo != null) addCondition(field, '==', isEqualTo);
|
||||
|
||||
_conditions = conditions;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete() async {
|
||||
final docs = await _utils.get(path, true, _conditions);
|
||||
if (docs != null) {
|
||||
for (var key in docs.keys) {
|
||||
final id = key.split(pathSeparatorRegEx).last;
|
||||
DocumentRef(id, this)._data.clear();
|
||||
}
|
||||
}
|
||||
|
||||
await _utils.delete(path);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Map<String, dynamic>> get stream;
|
||||
|
||||
/// Fetch the documents for this collection
|
||||
Future<Map<String, dynamic>?> get();
|
||||
|
||||
/// Creates and returns a new [CollectionRef] with additional filter on
|
||||
/// specified [field]. [field] refers to a field in a document.
|
||||
///
|
||||
/// `where` is not implemented
|
||||
CollectionRef where(
|
||||
field, {
|
||||
isEqualTo,
|
||||
});
|
||||
|
||||
/// Delete collection
|
||||
///
|
||||
/// All collections and documents in this collection will be deleted.
|
||||
Future<void> delete();
|
||||
}
|
||||
|
|
@ -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 = <String, DocumentRef>{};
|
||||
|
||||
/// Returns an instance using the default [DocumentRef].
|
||||
factory DocumentRef(String id, [CollectionRef? delegate]) {
|
||||
final key = '${delegate?.path ?? ''}$id';
|
||||
return _cache.putIfAbsent(key, () => DocumentRef._(id, delegate));
|
||||
}
|
||||
|
||||
/// A string representing the path of the referenced document (relative to the
|
||||
/// root of the database).
|
||||
String get path => '${_delegate?.path}$id';
|
||||
|
||||
final _utils = Utils.instance;
|
||||
|
||||
final Map<String, dynamic> _data = {};
|
||||
|
||||
@override
|
||||
Future<dynamic> set(Map<String, dynamic> data, [SetOptions? options]) async {
|
||||
options ??= SetOptions();
|
||||
if (options.merge) {
|
||||
final output = Map<String, dynamic>.from(data);
|
||||
Map<String, dynamic>? input = _data[id] ?? {};
|
||||
output.updateAll((key, value) {
|
||||
input![key] = value;
|
||||
});
|
||||
_data[id] = input;
|
||||
} else {
|
||||
_data[id] = data;
|
||||
}
|
||||
_utils.set(_data[id], path);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>?> get() async {
|
||||
return _data[id] ?? await _utils.get(path);
|
||||
}
|
||||
|
||||
@override
|
||||
Future delete() async {
|
||||
await _utils.delete(path);
|
||||
_data.remove(id);
|
||||
}
|
||||
|
||||
@override
|
||||
CollectionRef collection(String id) {
|
||||
return CollectionRef(id, _delegate, this);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return _utils.toString();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<dynamic> set(Map<String, dynamic> data, [SetOptions? options]);
|
||||
|
||||
/// Reads the document referenced by this [DocumentRef].
|
||||
Future<Map<String, dynamic>?> get();
|
||||
|
||||
/// Deletes the current document from the collection.
|
||||
Future delete();
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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<Directory> get databaseDirectory => _databaseDirectory;
|
||||
|
||||
/// Clears the cache - needed only if filesystem has been manipulated directly
|
||||
void clearCache() {
|
||||
Utils.instance.clearCache();
|
||||
}
|
||||
|
||||
@override
|
||||
CollectionRef collection(String path) {
|
||||
return CollectionRef(path, null, _delegate);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<Map<String, dynamic>?> get(String path,
|
||||
[bool? isCollection = false, List<List>? conditions]) async {
|
||||
// Fetch the documents for this collection
|
||||
if (isCollection != null && isCollection == true) {
|
||||
var dataCol = html.window.localStorage.entries.singleWhere(
|
||||
(e) => e.key == path,
|
||||
orElse: () => const MapEntry('', ''),
|
||||
);
|
||||
if (dataCol.key != '') {
|
||||
if (conditions != null && conditions.first.isNotEmpty) {
|
||||
return _getAll(dataCol);
|
||||
/*
|
||||
final ck = conditions.first[0] as String;
|
||||
final co = conditions.first[1];
|
||||
final cv = conditions.first[2];
|
||||
// With conditions
|
||||
try {
|
||||
final mapCol = json.decode(dataCol.value) as Map<String, dynamic>;
|
||||
final its = SplayTreeMap.of(mapCol);
|
||||
its.removeWhere((key, value) {
|
||||
if (value is Map<String, dynamic>) {
|
||||
final key = value.keys.contains(ck);
|
||||
final check = value[ck] as bool;
|
||||
return !(key == true && check == cv);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
its.forEach((key, value) {
|
||||
final data = value as Map<String, dynamic>;
|
||||
_data[key] = data;
|
||||
});
|
||||
return _data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
*/
|
||||
} else {
|
||||
return _getAll(dataCol);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
final data = await _readFromStorage(path);
|
||||
final id = path.substring(path.lastIndexOf('/') + 1, path.length);
|
||||
if (data is Map<String, dynamic>) {
|
||||
if (data.containsKey(id)) return data[id];
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<dynamic>? set(Map<String, dynamic> data, String path) {
|
||||
return _writeToStorage(data, path);
|
||||
}
|
||||
|
||||
@override
|
||||
Future delete(String path) async {
|
||||
_deleteFromStorage(path);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Map<String, dynamic>> stream(String path, [List<List>? conditions]) {
|
||||
// ignore: close_sinks
|
||||
final storage = _storageCache[path] ??
|
||||
_storageCache.putIfAbsent(
|
||||
path, () => StreamController<Map<String, dynamic>>.broadcast());
|
||||
|
||||
_initStream(storage, path);
|
||||
return storage.stream;
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _getAll(MapEntry<String, String> dataCol) {
|
||||
final items = <String, dynamic>{};
|
||||
try {
|
||||
final mapCol = json.decode(dataCol.value) as Map<String, dynamic>;
|
||||
mapCol.forEach((key, value) {
|
||||
final data = value as Map<String, dynamic>;
|
||||
items[key] = data;
|
||||
});
|
||||
if (items.isEmpty) return null;
|
||||
return items;
|
||||
} catch (error) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void _initStream(
|
||||
StreamController<Map<String, dynamic>> storage, String path) {
|
||||
var dataCol = html.window.localStorage.entries.singleWhere(
|
||||
(e) => e.key == path,
|
||||
orElse: () => const MapEntry('', ''),
|
||||
);
|
||||
try {
|
||||
if (dataCol.key != '') {
|
||||
final mapCol = json.decode(dataCol.value) as Map<String, dynamic>;
|
||||
mapCol.forEach((key, value) {
|
||||
final data = value as Map<String, dynamic>;
|
||||
storage.add(data);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
final _storageCache = <String, StreamController<Map<String, dynamic>>>{};
|
||||
|
||||
Future<dynamic> _readFromStorage(String path) async {
|
||||
final key = path.replaceAll(RegExp(r'[^\/]+\/?$'), '');
|
||||
final data = html.window.localStorage.entries.firstWhere(
|
||||
(i) => i.key == key,
|
||||
orElse: () => const MapEntry('', ''),
|
||||
);
|
||||
if (data != const MapEntry('', '')) {
|
||||
try {
|
||||
return json.decode(data.value) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<dynamic> _writeToStorage(
|
||||
Map<String, dynamic> data,
|
||||
String path,
|
||||
) async {
|
||||
final key = path.replaceAll(RegExp(r'[^\/]+\/?$'), '');
|
||||
|
||||
final uri = Uri.parse(path);
|
||||
final id = uri.pathSegments.last;
|
||||
var dataCol = html.window.localStorage.entries.singleWhere(
|
||||
(e) => e.key == key,
|
||||
orElse: () => const MapEntry('', ''),
|
||||
);
|
||||
try {
|
||||
if (dataCol.key != '') {
|
||||
final mapCol = json.decode(dataCol.value) as Map<String, dynamic>;
|
||||
mapCol[id] = data;
|
||||
dataCol = MapEntry(id, json.encode(mapCol));
|
||||
html.window.localStorage.update(
|
||||
key,
|
||||
(value) => dataCol.value,
|
||||
ifAbsent: () => dataCol.value,
|
||||
);
|
||||
} else {
|
||||
html.window.localStorage.update(
|
||||
key,
|
||||
(value) => json.encode({id: data}),
|
||||
ifAbsent: () => json.encode({id: data}),
|
||||
);
|
||||
}
|
||||
// ignore: close_sinks
|
||||
final storage = _storageCache[key] ??
|
||||
_storageCache.putIfAbsent(
|
||||
key, () => StreamController<Map<String, dynamic>>.broadcast());
|
||||
|
||||
storage.sink.add(data);
|
||||
} catch (error) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<dynamic> _deleteFromStorage(String path) async {
|
||||
if (path.endsWith('/')) {
|
||||
// If path is a directory path
|
||||
final dataCol = html.window.localStorage.entries.singleWhere(
|
||||
(element) => element.key == path,
|
||||
orElse: () => const MapEntry('', ''),
|
||||
);
|
||||
|
||||
try {
|
||||
if (dataCol.key != '') {
|
||||
html.window.localStorage.remove(dataCol.key);
|
||||
}
|
||||
} catch (error) {
|
||||
rethrow;
|
||||
}
|
||||
} else {
|
||||
// If path is a file path
|
||||
final uri = Uri.parse(path);
|
||||
final key = path.replaceAll(RegExp(r'[^\/]+\/?$'), '');
|
||||
final id = uri.pathSegments.last;
|
||||
var dataCol = html.window.localStorage.entries.singleWhere(
|
||||
(e) => e.key == key,
|
||||
orElse: () => const MapEntry('', ''),
|
||||
);
|
||||
|
||||
try {
|
||||
if (dataCol.key != '') {
|
||||
final mapCol = json.decode(dataCol.value) as Map<String, dynamic>;
|
||||
mapCol.remove(id);
|
||||
html.window.localStorage.update(
|
||||
key,
|
||||
(value) => json.encode(mapCol),
|
||||
ifAbsent: () => dataCol.value,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = <String, StreamController<Map<String, dynamic>>>{};
|
||||
final _fileCache = <String, File>{};
|
||||
|
||||
/// Clears the cache
|
||||
@override
|
||||
void clearCache() {
|
||||
_storageCache.clear();
|
||||
_fileCache.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>?> get(String path,
|
||||
[bool? isCollection = false, List<List>? conditions]) async {
|
||||
// Fetch the documents for this collection
|
||||
if (isCollection != null && isCollection == true) {
|
||||
final dbDir = await Localstore.instance.databaseDirectory;
|
||||
final fullPath = '${dbDir.path}$path';
|
||||
final dir = Directory(fullPath);
|
||||
if (!await dir.exists()) {
|
||||
return {};
|
||||
}
|
||||
List<FileSystemEntity> entries =
|
||||
dir.listSync(recursive: false).whereType<File>().toList();
|
||||
return await _getAll(entries);
|
||||
} else {
|
||||
try {
|
||||
// Reads the document referenced by this [DocumentRef].
|
||||
final file = await _getFile(path);
|
||||
final randomAccessFile = file!.openSync(mode: FileMode.append);
|
||||
final data = await _readFile(randomAccessFile);
|
||||
randomAccessFile.closeSync();
|
||||
if (data is Map<String, dynamic>) {
|
||||
final key = path.replaceAll(lastPathComponentRegEx, '');
|
||||
// ignore: close_sinks
|
||||
final storage = _storageCache.putIfAbsent(key, () => _newStream(key));
|
||||
storage.add(data);
|
||||
return data;
|
||||
}
|
||||
} on PathNotFoundException {
|
||||
// return null if not found
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<dynamic>? set(Map<String, dynamic> data, String path) {
|
||||
return _writeFile(data, path);
|
||||
}
|
||||
|
||||
@override
|
||||
Future delete(String path) async {
|
||||
if (path.endsWith(Platform.pathSeparator)) {
|
||||
await _deleteDirectory(path);
|
||||
} else {
|
||||
await _deleteFile(path);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Map<String, dynamic>> stream(String path, [List<List>? conditions]) {
|
||||
// ignore: close_sinks
|
||||
var storage = _storageCache[path];
|
||||
if (storage == null) {
|
||||
storage = _storageCache.putIfAbsent(path, () => _newStream(path));
|
||||
} else {
|
||||
_initStream(storage, path);
|
||||
}
|
||||
return storage.stream;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> _getAll(List<FileSystemEntity> entries) async {
|
||||
final items = <String, dynamic>{};
|
||||
final dbDir = await Localstore.instance.databaseDirectory;
|
||||
await Future.forEach(entries, (FileSystemEntity e) async {
|
||||
final path = e.path.replaceAll(dbDir.path, '');
|
||||
final file = await _getFile(path);
|
||||
try {
|
||||
final randomAccessFile = await file!.open(mode: FileMode.append);
|
||||
final data = await _readFile(randomAccessFile);
|
||||
await randomAccessFile.close();
|
||||
|
||||
if (data is Map<String, dynamic>) {
|
||||
items[path] = data;
|
||||
}
|
||||
} on PathNotFoundException {
|
||||
// ignore if not found
|
||||
}
|
||||
});
|
||||
|
||||
if (items.isEmpty) return null;
|
||||
return items;
|
||||
}
|
||||
|
||||
/// Streams all file in the path
|
||||
StreamController<Map<String, dynamic>> _newStream(String path) {
|
||||
final storage = StreamController<Map<String, dynamic>>.broadcast();
|
||||
_initStream(storage, path);
|
||||
|
||||
return storage;
|
||||
}
|
||||
|
||||
Future _initStream(
|
||||
StreamController<Map<String, dynamic>> storage,
|
||||
String path,
|
||||
) async {
|
||||
final dbDir = await Localstore.instance.databaseDirectory;
|
||||
final fullPath = '${dbDir.path}$path';
|
||||
final dir = Directory(fullPath);
|
||||
try {
|
||||
List<FileSystemEntity> entries =
|
||||
dir.listSync(recursive: false).whereType<File>().toList();
|
||||
for (var e in entries) {
|
||||
final path = e.path.replaceAll(dbDir.path, '');
|
||||
final file = await _getFile(path);
|
||||
final randomAccessFile = file!.openSync(mode: FileMode.append);
|
||||
_readFile(randomAccessFile).then((data) {
|
||||
randomAccessFile.closeSync();
|
||||
if (data is Map<String, dynamic>) {
|
||||
storage.add(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
|
||||
Future<dynamic> _readFile(RandomAccessFile file) async {
|
||||
final length = file.lengthSync();
|
||||
file.setPositionSync(0);
|
||||
final buffer = Uint8List(length);
|
||||
file.readIntoSync(buffer);
|
||||
try {
|
||||
final contentText = utf8.decode(buffer);
|
||||
final data = json.decode(contentText) as Map<String, dynamic>;
|
||||
return data;
|
||||
} catch (e) {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
|
||||
Future<File?> _getFile(String path) async {
|
||||
if (_fileCache.containsKey(path)) return _fileCache[path];
|
||||
|
||||
final dbDir = await Localstore.instance.databaseDirectory;
|
||||
|
||||
final file = File('${dbDir.path}$path');
|
||||
|
||||
if (!file.existsSync()) file.createSync(recursive: true);
|
||||
_fileCache.putIfAbsent(path, () => file);
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
Future _writeFile(Map<String, dynamic> data, String path) async {
|
||||
final serialized = json.encode(data);
|
||||
final buffer = utf8.encode(serialized);
|
||||
final file = await _getFile(path);
|
||||
try {
|
||||
final randomAccessFile = file!.openSync(mode: FileMode.append);
|
||||
randomAccessFile.lockSync();
|
||||
randomAccessFile.setPositionSync(0);
|
||||
randomAccessFile.writeFromSync(buffer);
|
||||
randomAccessFile.truncateSync(buffer.length);
|
||||
randomAccessFile.unlockSync();
|
||||
randomAccessFile.closeSync();
|
||||
} on PathNotFoundException {
|
||||
// ignore if path not found
|
||||
}
|
||||
final key = path.replaceAll(lastPathComponentRegEx, '');
|
||||
// ignore: close_sinks
|
||||
final storage = _storageCache.putIfAbsent(key, () => _newStream(key));
|
||||
storage.add(data);
|
||||
}
|
||||
|
||||
Future _deleteFile(String path) async {
|
||||
final dbDir = await Localstore.instance.databaseDirectory;
|
||||
final file = File('${dbDir.path}$path');
|
||||
if (await file.exists()) {
|
||||
try {
|
||||
await file.delete();
|
||||
} catch (e) {
|
||||
_log.finest(e);
|
||||
}
|
||||
}
|
||||
_fileCache.remove(path);
|
||||
}
|
||||
|
||||
Future _deleteDirectory(String path) async {
|
||||
final dbDir = await Localstore.instance.databaseDirectory;
|
||||
final dir = Directory('${dbDir.path}$path');
|
||||
if (await dir.exists()) {
|
||||
try {
|
||||
await dir.delete(recursive: true);
|
||||
} catch (e) {
|
||||
_log.finest(e);
|
||||
}
|
||||
}
|
||||
_fileCache.removeWhere((key, value) => key.startsWith(path));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
abstract class UtilsImpl {
|
||||
void clearCache();
|
||||
Future<Map<String, dynamic>?> get(String path,
|
||||
[bool? isCollection = false, List<List>? conditions]);
|
||||
Future<dynamic>? set(Map<String, dynamic> data, String path);
|
||||
Future delete(String path);
|
||||
Stream<Map<String, dynamic>> stream(String path, [List<List>? conditions]);
|
||||
}
|
||||
|
|
@ -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<Task> tasks;
|
||||
final BatchProgressCallback? batchProgressCallback;
|
||||
final results = <Task, TaskStatus>{};
|
||||
|
||||
Batch(this.tasks, this.batchProgressCallback);
|
||||
|
||||
/// Returns an Iterable with successful tasks in this batch
|
||||
Iterable<Task> get succeeded => results.entries
|
||||
.where((entry) => entry.value == TaskStatus.complete)
|
||||
.map((e) => e.key);
|
||||
|
||||
/// Returns the number of successful tasks in this batch
|
||||
int get numSucceeded =>
|
||||
results.values.where((result) => result == TaskStatus.complete).length;
|
||||
|
||||
/// Returns an Iterable with failed tasks in this batch
|
||||
Iterable<Task> get failed => results.entries
|
||||
.where((entry) => entry.value != TaskStatus.complete)
|
||||
.map((e) => e.key);
|
||||
|
||||
/// Returns the number of failed downloads in this batch
|
||||
int get numFailed => results.values.length - numSucceeded;
|
||||
}
|
||||
|
||||
/// Base class for updates related to [task]. Actual updates are
|
||||
/// either a status update or a progress update.
|
||||
///
|
||||
/// When receiving an update, test if the update is a
|
||||
/// [TaskStatusUpdate] or a [TaskProgressUpdate]
|
||||
/// and treat the update accordingly
|
||||
sealed class TaskUpdate {
|
||||
final Task task;
|
||||
|
||||
const TaskUpdate(this.task);
|
||||
|
||||
/// Create object from [json]
|
||||
TaskUpdate.fromJson(Map<String, dynamic> json)
|
||||
: task = Task.createFromJson(json['task'] ?? json);
|
||||
|
||||
/// Return JSON Map representing object
|
||||
Map<String, dynamic> toJson() => {'task': task.toJson()};
|
||||
}
|
||||
|
||||
/// A status update
|
||||
///
|
||||
/// Contains [TaskStatus] and, if [TaskStatus.failed] possibly a
|
||||
/// [TaskException] and if this is a final state possibly [responseBody],
|
||||
/// [responseHeaders], [responseStatusCode], [mimeType] and [charSet].
|
||||
/// Note: header names in [responseHeaders] are converted to lowercase
|
||||
class TaskStatusUpdate extends TaskUpdate {
|
||||
final TaskStatus status; // note: serialized as 'taskStatus'
|
||||
final TaskException? exception;
|
||||
final String? responseBody;
|
||||
final int? responseStatusCode;
|
||||
final Map<String, String>? responseHeaders;
|
||||
final String? mimeType; // derived from Content-Type header
|
||||
final String? charSet; // derived from Content-Type header
|
||||
|
||||
const TaskStatusUpdate(super.task, this.status,
|
||||
[this.exception,
|
||||
this.responseBody,
|
||||
this.responseHeaders,
|
||||
this.responseStatusCode,
|
||||
this.mimeType,
|
||||
this.charSet]);
|
||||
|
||||
/// Create object from [json]
|
||||
TaskStatusUpdate.fromJson(super.json)
|
||||
: status = TaskStatus.values[(json['taskStatus'] as num?)?.toInt() ?? 0],
|
||||
exception = json['exception'] != null
|
||||
? TaskException.fromJson(json['exception'])
|
||||
: null,
|
||||
responseBody = json['responseBody'],
|
||||
responseHeaders = json['responseHeaders'] != null
|
||||
? Map.from(json['responseHeaders'])
|
||||
: null,
|
||||
responseStatusCode = (json['responseStatusCode'] as num?)?.toInt(),
|
||||
mimeType = json['mimeType'],
|
||||
charSet = json['charSet'],
|
||||
super.fromJson();
|
||||
|
||||
/// Create object from [jsonString]
|
||||
factory TaskStatusUpdate.fromJsonString(String jsonString) =>
|
||||
TaskStatusUpdate.fromJson(jsonDecode(jsonString));
|
||||
|
||||
/// Return JSON Map representing object
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {
|
||||
...super.toJson(),
|
||||
'taskStatus': status.index,
|
||||
'exception': exception?.toJson(),
|
||||
'responseBody': responseBody,
|
||||
'responseHeaders': responseHeaders,
|
||||
'responseStatusCode': responseStatusCode,
|
||||
'mimeType': mimeType,
|
||||
'charSet': charSet
|
||||
};
|
||||
|
||||
TaskStatusUpdate copyWith(
|
||||
{Task? task,
|
||||
TaskStatus? status,
|
||||
TaskException? exception,
|
||||
String? responseBody,
|
||||
Map<String, String>? responseHeaders,
|
||||
int? responseStatusCode,
|
||||
String? mimeType,
|
||||
String? charSet}) =>
|
||||
TaskStatusUpdate(
|
||||
task ?? this.task,
|
||||
status ?? this.status,
|
||||
exception ?? this.exception,
|
||||
responseBody ?? this.responseBody,
|
||||
responseHeaders ?? this.responseHeaders,
|
||||
responseStatusCode ?? this.responseStatusCode,
|
||||
mimeType ?? this.mimeType,
|
||||
charSet ?? this.charSet);
|
||||
}
|
||||
|
||||
/// A progress update
|
||||
///
|
||||
/// A successfully downloaded task will always finish with progress 1.0
|
||||
///
|
||||
/// [TaskStatus.failed] results in progress -1.0
|
||||
/// [TaskStatus.canceled] results in progress -2.0
|
||||
/// [TaskStatus.notFound] results in progress -3.0
|
||||
/// [TaskStatus.waitingToRetry] results in progress -4.0
|
||||
///
|
||||
/// [expectedFileSize] will only be representative if the 0 < [progress] < 1,
|
||||
/// so NOT representative when progress == 0 or progress == 1, and
|
||||
/// will be -1 if the file size is not provided by the server or otherwise
|
||||
/// not known.
|
||||
/// [networkSpeed] is valid if positive, expressed in MB/second
|
||||
/// [timeRemaining] is valid if positive
|
||||
///
|
||||
/// Use the [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<String, dynamic> toJson() => {
|
||||
...super.toJson(),
|
||||
'progress': progress,
|
||||
'expectedFileSize': expectedFileSize,
|
||||
'networkSpeed': networkSpeed,
|
||||
'timeRemaining': timeRemaining.inSeconds
|
||||
};
|
||||
|
||||
/// If true, [expectedFileSize] contains a valid value
|
||||
bool get hasExpectedFileSize => expectedFileSize >= 0;
|
||||
|
||||
/// If true, [networkSpeed] contains a valid value
|
||||
bool get hasNetworkSpeed => networkSpeed >= 0;
|
||||
|
||||
/// If true, [timeRemaining] contains a valid value
|
||||
bool get hasTimeRemaining => !timeRemaining.isNegative;
|
||||
|
||||
/// 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<String, dynamic> json)
|
||||
: task = Task.createFromJson(json['task']),
|
||||
data = json['data'] as String,
|
||||
requiredStartByte = (json['requiredStartByte'] as num?)?.toInt() ?? 0,
|
||||
eTag = json['eTag'] as String?;
|
||||
|
||||
/// Create object from [jsonString]
|
||||
factory ResumeData.fromJsonString(String jsonString) =>
|
||||
ResumeData.fromJson(jsonDecode(jsonString));
|
||||
|
||||
/// Return JSON Map representing object
|
||||
Map<String, dynamic> toJson() => {
|
||||
'task': task.toJson(),
|
||||
'data': data,
|
||||
'requiredStartByte': requiredStartByte,
|
||||
'eTag': eTag
|
||||
};
|
||||
|
||||
String get taskId => task.taskId;
|
||||
|
||||
/// The tempFilepath contained in the [data] field
|
||||
String get tempFilepath => data;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ResumeData &&
|
||||
runtimeType == other.runtimeType &&
|
||||
task == other.task &&
|
||||
data == other.data &&
|
||||
requiredStartByte == other.requiredStartByte &&
|
||||
eTag == other.eTag;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
task.hashCode ^
|
||||
data.hashCode ^
|
||||
requiredStartByte.hashCode ^
|
||||
(eTag?.hashCode ?? 0);
|
||||
}
|
||||
|
||||
/// Types of undelivered data that can be requested
|
||||
enum Undelivered { 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<String, dynamic> 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<String, dynamic> 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 }
|
||||
|
|
@ -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<void> storeTaskRecord(TaskRecord record);
|
||||
|
||||
/// Retrieve [TaskRecord] with [taskId], or null if not found
|
||||
Future<TaskRecord?> retrieveTaskRecord(String taskId);
|
||||
|
||||
/// Retrieve all [TaskRecord]
|
||||
Future<List<TaskRecord>> retrieveAllTaskRecords();
|
||||
|
||||
/// Remove [TaskRecord] with [taskId] from storage. If null, remove all
|
||||
Future<void> removeTaskRecord(String? taskId);
|
||||
|
||||
/// Store a paused [task], keyed by taskId
|
||||
Future<void> storePausedTask(Task task);
|
||||
|
||||
/// Retrieve paused [Task] with [taskId], or null if not found
|
||||
Future<Task?> retrievePausedTask(String taskId);
|
||||
|
||||
/// Retrieve all paused [Task]
|
||||
Future<List<Task>> retrieveAllPausedTasks();
|
||||
|
||||
/// Remove paused [Task] with [taskId] from storage. If null, remove all
|
||||
Future<void> removePausedTask(String? taskId);
|
||||
|
||||
/// Store [ResumeData], keyed by its taskId
|
||||
Future<void> storeResumeData(ResumeData resumeData);
|
||||
|
||||
/// Retrieve [ResumeData] with [taskId], or null if not found
|
||||
Future<ResumeData?> retrieveResumeData(String taskId);
|
||||
|
||||
/// Retrieve all [ResumeData]
|
||||
Future<List<ResumeData>> retrieveAllResumeData();
|
||||
|
||||
/// Remove [ResumeData] with [taskId] from storage. If null, remove all
|
||||
Future<void> removeResumeData(String? taskId);
|
||||
|
||||
/// Name and version number for this type of persistent storage
|
||||
///
|
||||
/// Used for database migration: this is the version represented by the code
|
||||
(String, int) get currentDatabaseVersion;
|
||||
|
||||
/// Name and version number for database as stored
|
||||
///
|
||||
/// Used for database migration, may be 'older' than the code version
|
||||
Future<(String, int)> get storedDatabaseVersion;
|
||||
|
||||
/// Initialize the database - only called when the [BaseDownloader]
|
||||
/// is created with this object, which happens when the [FileDownloader]
|
||||
/// singleton is instantiated, OR as part of a migration away from this
|
||||
/// database type.
|
||||
///
|
||||
/// Migrates the data from stored name and version to the current
|
||||
/// name and version, if needed
|
||||
/// This call runs async with the rest of the initialization
|
||||
Future<void> initialize();
|
||||
}
|
||||
|
||||
/// 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<String, dynamic>] formatted [document] in [collection] keyed under [identifier]
|
||||
Future<void> store(Map<String, dynamic> document, String collection,
|
||||
String identifier) async {
|
||||
await _db.collection(collection).doc(identifier).set(document);
|
||||
}
|
||||
|
||||
/// Returns [document] stored in [collection] under key [identifier]
|
||||
/// as a [Map<String, dynamic>], or null if not found
|
||||
Future<Map<String, dynamic>?> retrieve(
|
||||
String collection, String identifier) =>
|
||||
_db.collection(collection).doc(identifier).get();
|
||||
|
||||
/// Returns all documents in collection as a [Map<String, dynamic>] keyed by the
|
||||
/// document identifier, with the value a [Map<String, dynamic>] representing the document
|
||||
Future<Map<String, dynamic>> retrieveAll(String collection) async {
|
||||
return await _db.collection(collection).get() ?? {};
|
||||
}
|
||||
|
||||
/// Removes document with [identifier] from [collection]
|
||||
///
|
||||
/// If [identifier] is null, removes all documents in the [collection]
|
||||
Future<void> remove(String collection, [String? identifier]) async {
|
||||
if (identifier == null) {
|
||||
await _db.collection(collection).delete();
|
||||
} else {
|
||||
await _db.collection(collection).doc(identifier).delete();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns possibly modified id, safe for storing in the localStore
|
||||
String _safeId(String id) => id.replaceAll(_illegalPathCharacters, '_');
|
||||
|
||||
/// Returns possibly modified id, safe for storing in the localStore, or null
|
||||
/// if [id] is null
|
||||
String? _safeIdOrNull(String? id) =>
|
||||
id?.replaceAll(_illegalPathCharacters, '_');
|
||||
|
||||
@override
|
||||
Future<void> removePausedTask(String? taskId) =>
|
||||
remove(pausedTasksPath, _safeIdOrNull(taskId));
|
||||
|
||||
@override
|
||||
Future<void> removeResumeData(String? taskId) =>
|
||||
remove(resumeDataPath, _safeIdOrNull(taskId));
|
||||
|
||||
@override
|
||||
Future<void> removeTaskRecord(String? taskId) =>
|
||||
remove(taskRecordsPath, _safeIdOrNull(taskId));
|
||||
|
||||
@override
|
||||
Future<List<Task>> retrieveAllPausedTasks() async {
|
||||
final jsonMaps = await retrieveAll(pausedTasksPath);
|
||||
return jsonMaps.values
|
||||
.map((e) => Task.createFromJson(e))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ResumeData>> retrieveAllResumeData() async {
|
||||
final jsonMaps = await retrieveAll(resumeDataPath);
|
||||
return jsonMaps.values
|
||||
.map((e) => ResumeData.fromJson(e))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<TaskRecord>> retrieveAllTaskRecords() async {
|
||||
final jsonMaps = await retrieveAll(taskRecordsPath);
|
||||
return jsonMaps.values
|
||||
.map((e) => TaskRecord.fromJson(e))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Task?> retrievePausedTask(String taskId) async {
|
||||
return switch (await retrieve(pausedTasksPath, _safeId(taskId))) {
|
||||
var json? => Task.createFromJson(json),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ResumeData?> retrieveResumeData(String taskId) async {
|
||||
return switch (await retrieve(resumeDataPath, _safeId(taskId))) {
|
||||
var json? => ResumeData.fromJson(json),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TaskRecord?> retrieveTaskRecord(String taskId) async {
|
||||
return switch (await retrieve(taskRecordsPath, _safeId(taskId))) {
|
||||
var json? => TaskRecord.fromJson(json),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> storePausedTask(Task task) =>
|
||||
store(task.toJson(), pausedTasksPath, _safeId(task.taskId));
|
||||
|
||||
@override
|
||||
Future<void> storeResumeData(ResumeData resumeData) =>
|
||||
store(resumeData.toJson(), resumeDataPath, _safeId(resumeData.taskId));
|
||||
|
||||
@override
|
||||
Future<void> 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<void> initialize() async {
|
||||
final (currentName, currentVersion) = currentDatabaseVersion;
|
||||
final (storedName, storedVersion) = await storedDatabaseVersion;
|
||||
if (storedName != currentName) {
|
||||
log.warning('Cannot migrate from database name $storedName');
|
||||
return;
|
||||
}
|
||||
if (storedVersion == currentVersion) {
|
||||
return;
|
||||
}
|
||||
log.fine(
|
||||
'Migrating $currentName database from version $storedVersion to $currentVersion');
|
||||
switch (storedVersion) {
|
||||
case 0:
|
||||
// move files from docDir to supportDir
|
||||
final docDir = await getApplicationDocumentsDirectory();
|
||||
final supportDir = await getApplicationSupportDirectory();
|
||||
for (String path in [
|
||||
resumeDataPath,
|
||||
pausedTasksPath,
|
||||
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<String?> migrate(
|
||||
List<String> migrationOptions, PersistentStorage toStorage);
|
||||
}
|
||||
|
||||
/// Migrates from [LocalStorePersistentStorage] to another [PersistentStorage]
|
||||
class BasePersistentStorageMigrator implements PersistentStorageMigrator {
|
||||
final log = Logger('PersistentStorageMigrator');
|
||||
|
||||
/// Create [BasePersistentStorageMigrator] object to migrate between persistent
|
||||
/// storage solutions
|
||||
///
|
||||
/// [BasePersistentStorageMigrator] only migrates from:
|
||||
/// * local_store (the default implementation of the database in
|
||||
/// background_downloader).
|
||||
///
|
||||
/// To add other migrations, extend this class and inject it in the
|
||||
/// [PersistentStorage] class that you want to migrate to.
|
||||
///
|
||||
/// See package background_downloader_sql for an implementation
|
||||
/// that migrates to a SQLite based [PersistentStorage], including
|
||||
/// migration from Flutter Downloader
|
||||
BasePersistentStorageMigrator();
|
||||
|
||||
/// Migrate data from one of the [migrationOptions] to the [toStorage]
|
||||
///
|
||||
/// If migration took place, returns the name of the migration option,
|
||||
/// otherwise returns null
|
||||
///
|
||||
/// This is the public interface to use in other [PersistentStorage]
|
||||
/// solutions.
|
||||
@override
|
||||
Future<String?> migrate(
|
||||
List<String> migrationOptions, PersistentStorage toStorage) async {
|
||||
for (var persistentStorageName in migrationOptions) {
|
||||
try {
|
||||
if (await migrateFrom(persistentStorageName, toStorage)) {
|
||||
return persistentStorageName;
|
||||
}
|
||||
} on Exception catch (e, stacktrace) {
|
||||
log.warning(
|
||||
'Error attempting to migrate from $persistentStorageName: $e\n$stacktrace');
|
||||
}
|
||||
}
|
||||
return null; // no migration
|
||||
}
|
||||
|
||||
/// Attempt to migrate data from [persistentStorageName] to [toStorage]
|
||||
///
|
||||
/// Returns true if the migration was successfully executed, false if it
|
||||
/// was not a viable migration
|
||||
///
|
||||
/// If extending the class, add your mapping from a migration option String
|
||||
/// to a _migrateFrom... method that does your migration.
|
||||
Future<bool> migrateFrom(
|
||||
String persistentStorageName, PersistentStorage toStorage) =>
|
||||
switch (persistentStorageName.toLowerCase().replaceAll('_', '')) {
|
||||
'localstore' => migrateFromLocalStore(toStorage),
|
||||
_ => Future.value(false)
|
||||
};
|
||||
|
||||
/// Migrate from a persistent storage to our database
|
||||
///
|
||||
/// Returns true if this migration took place
|
||||
///
|
||||
/// This is a generic migrator that copies from one storage to another, and
|
||||
/// is used by the _migrateFrom... methods
|
||||
Future<bool> migrateFromPersistentStorage(
|
||||
PersistentStorage fromStorage, PersistentStorage toStorage) async {
|
||||
bool migratedSomething = false;
|
||||
await fromStorage.initialize();
|
||||
for (final pausedTask in await fromStorage.retrieveAllPausedTasks()) {
|
||||
await toStorage.storePausedTask(pausedTask);
|
||||
migratedSomething = true;
|
||||
}
|
||||
for (final 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<bool> migrateFromLocalStore(PersistentStorage toStorage) async {
|
||||
final localStore = LocalStorePersistentStorage();
|
||||
if (await migrateFromPersistentStorage(localStore, toStorage)) {
|
||||
// delete all paths related to LocalStore
|
||||
final supportDir = await getApplicationSupportDirectory();
|
||||
for (String collectionPath in [
|
||||
LocalStorePersistentStorage.resumeDataPath,
|
||||
LocalStorePersistentStorage.pausedTasksPath,
|
||||
LocalStorePersistentStorage.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
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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<String, String>? urlQueryParameters) {
|
||||
if (urlQueryParameters == null || urlQueryParameters.isEmpty) {
|
||||
return url;
|
||||
}
|
||||
final separator = url.contains('?') ? '&' : '?';
|
||||
return '$url$separator${urlQueryParameters.entries.map((e) => '${e.key}=${e.value}').join('&')}';
|
||||
}
|
||||
|
||||
/// Parses the range in a Range header, and returns a Pair representing
|
||||
/// the range. The format needs to be "bytes=10-20"
|
||||
///
|
||||
/// A missing lower range is substituted with 0, and a missing upper
|
||||
/// range with null. If the string cannot be parsed, returns (0, null)
|
||||
(int, int?) parseRange(String rangeStr) {
|
||||
final regex = RegExp(r'bytes=(\d*)-(\d*)');
|
||||
final match = regex.firstMatch(rangeStr);
|
||||
if (match == null) {
|
||||
return (0, null);
|
||||
}
|
||||
|
||||
final start = int.tryParse(match.group(1) ?? '') ?? 0;
|
||||
final end = int.tryParse(match.group(2) ?? '');
|
||||
return (start, end);
|
||||
}
|
||||
|
||||
/// Returns the content length extracted from the [responseHeaders], or from
|
||||
/// the [task] headers
|
||||
int getContentLength(Map<String, String> responseHeaders, Task task) {
|
||||
// if response provides contentLength, return it
|
||||
final contentLength = int.tryParse(responseHeaders['Content-Length'] ??
|
||||
responseHeaders['content-length'] ??
|
||||
'-1');
|
||||
if (contentLength != null && contentLength != -1) {
|
||||
return contentLength;
|
||||
}
|
||||
// try extracting it from Range header
|
||||
final taskRangeHeader = task.headers['Range'] ?? task.headers['range'] ?? '';
|
||||
final taskRange = parseRange(taskRangeHeader);
|
||||
if (taskRange.$2 != null) {
|
||||
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<DownloadTask> taskWithSuggestedFilename(
|
||||
DownloadTask task, Map<String, String> responseHeaders, bool unique) {
|
||||
/// Returns [DownloadTask] with a filename similar to the one
|
||||
/// supplied, but unused.
|
||||
///
|
||||
/// If [unique], filename will sequence up in "filename (8).txt" format,
|
||||
/// otherwise returns the [task]
|
||||
Future<DownloadTask> uniqueFilename(DownloadTask task, bool unique) async {
|
||||
if (!unique) {
|
||||
return task;
|
||||
}
|
||||
final sequenceRegEx = RegExp(r'\((\d+)\)\.?[^.]*$');
|
||||
final extensionRegEx = RegExp(r'\.[^.]*$');
|
||||
var newTask = task;
|
||||
var filePath = await newTask.filePath();
|
||||
var exists = await File(filePath).exists();
|
||||
while (exists) {
|
||||
final extension =
|
||||
extensionRegEx.firstMatch(newTask.filename)?.group(0) ?? '';
|
||||
final match = sequenceRegEx.firstMatch(newTask.filename);
|
||||
final newSequence = int.parse(match?.group(1) ?? "0") + 1;
|
||||
final newFilename = match == null
|
||||
? '${path.basenameWithoutExtension(newTask.filename)} ($newSequence)$extension'
|
||||
: '${newTask.filename.substring(0, match.start - 1)} ($newSequence)$extension';
|
||||
newTask = newTask.copyWith(filename: newFilename);
|
||||
filePath = await newTask.filePath();
|
||||
exists = await File(filePath).exists();
|
||||
}
|
||||
return newTask;
|
||||
}
|
||||
|
||||
// start of main function
|
||||
try {
|
||||
final disposition = responseHeaders.entries
|
||||
.firstWhere(
|
||||
(element) => element.key.toLowerCase() == 'content-disposition')
|
||||
.value;
|
||||
// Try filename*=UTF-8'language'"encodedFilename"
|
||||
final encodedFilenameRegEx = RegExp(
|
||||
'filename\\*=\\s*([^\']+)\'([^\']*)\'"?([^"]+)"?',
|
||||
caseSensitive: false);
|
||||
var match = encodedFilenameRegEx.firstMatch(disposition);
|
||||
if (match != null &&
|
||||
match.group(1)?.isNotEmpty == true &&
|
||||
match.group(3)?.isNotEmpty == true) {
|
||||
try {
|
||||
final suggestedFilename = match.group(1)?.toUpperCase() == 'UTF-8'
|
||||
? Uri.decodeComponent(match.group(3)!)
|
||||
: match.group(3)!;
|
||||
return uniqueFilename(
|
||||
task.copyWith(filename: suggestedFilename), unique);
|
||||
} on ArgumentError {
|
||||
_log.finest(
|
||||
'Could not interpret suggested filename (UTF-8 url encoded) ${match.group(3)}');
|
||||
}
|
||||
}
|
||||
// Try filename="filename"
|
||||
final plainFilenameRegEx =
|
||||
RegExp(r'filename=\s*"?([^"]+)"?.*$', caseSensitive: false);
|
||||
match = plainFilenameRegEx.firstMatch(disposition);
|
||||
if (match != null && match.group(1)?.isNotEmpty == true) {
|
||||
return uniqueFilename(task.copyWith(filename: match.group(1)), unique);
|
||||
}
|
||||
} catch (_) {}
|
||||
_log.finest('Could not determine suggested filename from server');
|
||||
// Try filename derived from last path segment of the url
|
||||
try {
|
||||
final suggestedFilename = Uri.parse(task.url).pathSegments.last;
|
||||
return uniqueFilename(task.copyWith(filename: suggestedFilename), unique);
|
||||
} catch (_) {}
|
||||
_log.finest('Could not parse URL pathSegment for suggested filename');
|
||||
// if everything fails, return the task with unchanged filename
|
||||
// except for possibly making it unique
|
||||
return uniqueFilename(task, unique);
|
||||
}
|
||||
420
lib/services/download_manager/m3u8/m3u8_downloader.dart
Normal file
420
lib/services/download_manager/m3u8/m3u8_downloader.dart
Normal file
|
|
@ -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 = <String?, (ReceivePort?, Isolate?)>{};
|
||||
|
||||
class M3u8Downloader {
|
||||
final String m3u8Url;
|
||||
final String downloadDir;
|
||||
final Map<String, String>? 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<T> _withRetryStatic<T>(
|
||||
Future<T> 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<T> _withRetry<T>(Future<T> 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<TsInfo>, 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<void> 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<List<TsInfo>> _filterExistingSegments(
|
||||
List<TsInfo> tsList, String tempDir) async {
|
||||
return tsList
|
||||
.where((ts) => !File(path.join(tempDir, '${ts.name}.ts')).existsSync())
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> _downloadSegmentsWithProgress(
|
||||
List<TsInfo> 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<TsInfo>.from(params.segments!);
|
||||
final List<Future<void>> 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<void> _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<void> _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<void> _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<String> _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<TsInfo> _parseTsList(String host, String body) {
|
||||
final lines = body.split('\n');
|
||||
final tsList = <TsInfo>[];
|
||||
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)' : ''}';
|
||||
}
|
||||
55
lib/services/download_manager/m3u8/models/download.dart
Normal file
55
lib/services/download_manager/m3u8/models/download.dart
Normal file
|
|
@ -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<TsInfo>? segments;
|
||||
final String? tempDir;
|
||||
final Uint8List? key;
|
||||
final Uint8List? iv;
|
||||
final int? mediaSequence;
|
||||
final int? concurrentDownloads;
|
||||
final Map<String, String>? headers;
|
||||
final SendPort? sendPort;
|
||||
final List<PageUrl>? 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)';
|
||||
}
|
||||
}
|
||||
9
lib/services/download_manager/m3u8/models/ts_info.dart
Normal file
9
lib/services/download_manager/m3u8/models/ts_info.dart
Normal file
|
|
@ -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)';
|
||||
}
|
||||
218
lib/services/download_manager/m_downloader.dart
Normal file
218
lib/services/download_manager/m_downloader.dart
Normal file
|
|
@ -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<PageUrl> 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<T> _withRetryStatic<T>(
|
||||
Future<T> 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<void> download(void Function(DownloadProgress) onProgress) async {
|
||||
try {
|
||||
await _downloadFilesWithProgress(pageUrls, onProgress);
|
||||
} catch (e) {
|
||||
throw MDownloaderException('Download failed', e);
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _downloadFilesWithProgress(
|
||||
List<PageUrl> 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<PageUrl>.from(params.pageUrls!);
|
||||
final List<Future<void>> 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<void> _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<int> 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)' : ''}';
|
||||
}
|
||||
|
|
@ -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<String, String>? headers;
|
||||
M3u8Downloader(
|
||||
{required this.m3u8Url,
|
||||
required this.downloadDir,
|
||||
required this.headers});
|
||||
|
||||
Future<(List<TsInfo>, 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<String> _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<TsInfo> _parseTsList(String host, String body) {
|
||||
final lines = body.split("\n");
|
||||
List<TsInfo> 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<void> mergeTsToMp4(String fileName, String directory) async {
|
||||
await Isolate.run(() async {
|
||||
List<String> 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<void> 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ part of 'anilist.dart';
|
|||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$anilistHash() => r'ddd07acc8d28d2aa95c942566109e9393ca9e5ed';
|
||||
String _$anilistHash() => r'70e8cd537270a9054a1ef72de117fc7ad5545218';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
|
|
|
|||
|
|
@ -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<void> 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
26
pubspec.lock
26
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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
|
||||
#include <flutter_inappwebview_windows/flutter_inappwebview_windows_plugin_c_api.h>
|
||||
#include <flutter_qjs/flutter_qjs_plugin.h>
|
||||
#include <isar_flutter_libs/isar_flutter_libs_plugin.h>
|
||||
|
|
@ -20,6 +21,8 @@
|
|||
#include <window_to_front/window_to_front_plugin.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
||||
FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi"));
|
||||
FlutterQjsPluginRegisterWithRegistrar(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
connectivity_plus
|
||||
flutter_inappwebview_windows
|
||||
flutter_qjs
|
||||
isar_flutter_libs
|
||||
|
|
|
|||
Loading…
Reference in a new issue