[verified] Publish ReelBox source under GPLv3
This commit is contained in:
commit
db2b505ca3
445 files changed
+105274
No files matched your search
+37
@@ -0,0 +1,37 @@
|
||||
Copyright 2022 BBFlight LLC
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
---
|
||||
|
||||
This software includes a modified version of package localstore (https://pub.dev/packages/localstore/versions/1.3.5). The license for that software is:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 Hanoi University of Mining and Geology, Vietnam.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
# background_downloader 9.6.5 — RM003 patch
|
||||
|
||||
This is a runtime-only vendoring of the exact published `background_downloader`
|
||||
9.6.5 package, not a general dependency upgrade. `lib/`, `android/`, `ios/`,
|
||||
`pubspec.yaml`, and `LICENSE` were copied from that pinned package. Examples,
|
||||
upstream tests, and documentation are omitted to keep the fork small.
|
||||
|
||||
- Upstream: https://github.com/781flyingdutchman/background_downloader
|
||||
- Published archive: https://pub.dev/api/archives/background_downloader-9.6.5.tar.gz
|
||||
- Published archive SHA-256 (original application lockfile and pub cache):
|
||||
`4df9b2a2d5b78b883ebd4b6338cbc1b541ea3482da24112339468a66a7492b43`
|
||||
- Upstream `LICENSE` is retained verbatim, including its BSD-3-Clause terms and
|
||||
the bundled localstore MIT notice. Preserve these notices in redistribution.
|
||||
- Package version remains `9.6.5`; the root application's path override selects
|
||||
this reviewed copy.
|
||||
|
||||
## Only production changes
|
||||
|
||||
1. `lib/src/chunk.dart`: `resumeChunkTasks` resumes only chunks whose serialized
|
||||
status is not `TaskStatus.complete`. A completed chunk's final file and status
|
||||
must remain available to the real parent stitching worker. Completed chunks
|
||||
have no resume data; trying to resume them returns false and cancels the parent.
|
||||
2. `lib/src/desktop/desktop_downloader.dart`: read and retain the parent's resume
|
||||
snapshot before enqueue. Enqueue starts a desktop worker which consumes that
|
||||
record; rereading after enqueue races the worker and can return null. This
|
||||
separate race was reproduced while exercising the actual Linux workers.
|
||||
|
||||
## Download crash recovery fixes (0.4.0)
|
||||
|
||||
3. `lib/src/permissions.dart`: retain the current request's completer locally;
|
||||
a permission callback may arrive before the request method returns. Release the
|
||||
shared in-flight slot in `finally`, including dispatch failures.
|
||||
4. `lib/src/base_downloader.dart`: propagate initialization failures to `ready`,
|
||||
and mark readiness only after subclass/background-handler setup completes.
|
||||
Await initial connectivity so errors are owned by initialization.
|
||||
5. `lib/src/base_downloader.dart`: the database update queue reports write errors
|
||||
through its flush barrier, including failed-then-successful batches, while
|
||||
allowing later recovery. Detached futures no longer leak these errors.
|
||||
|
||||
The 0.4.0 fixes above did not modify native downloader sources. Dart regressions for
|
||||
those paths are in `test/download_android_*_test.dart`. They reproduce callback,
|
||||
initialization, connectivity and persistence failures on the host; they do not
|
||||
claim an Android process crash was reproduced without logcat/device evidence.
|
||||
|
||||
## In-app transfer fixes (0.4.2)
|
||||
|
||||
6. `lib/src/native_downloader.dart`: `enqueueChild` now awaits actual native
|
||||
enqueue and acknowledges its Boolean result. Rejection or a dispatch exception
|
||||
returns false, allowing the parent to fail instead of waiting forever for a
|
||||
nonexistent child. Background-channel tests cover rejection, a pending native
|
||||
acknowledgement and an exception without detached errors.
|
||||
7. Android `BDPlugin.kt` and `DownloadNetworkPolicy.java`: on API 28+, both
|
||||
WorkManager and user-initiated JobScheduler jobs use the same explicit INTERNET
|
||||
network request, without VALIDATED or NOT_VPN. Wi-Fi transport remains required
|
||||
when the effective task policy is Wi-Fi-only. This avoids making successful
|
||||
external OS connectivity validation a prerequisite for a reachable media host.
|
||||
Older Android versions retain their original compatible scheduler constraints.
|
||||
|
||||
Application-side changes are outside this fork: multipart is selected only after
|
||||
the server advertises the HEAD/length/range capabilities the native worker needs;
|
||||
otherwise a normal streaming GET is used. A manual retry of failed multipart work
|
||||
also uses a single stream and restores its ordinary retry budget.
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization' version '2.3.20'
|
||||
}
|
||||
|
||||
def agpMajor = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize('.')[0] as int
|
||||
def isBuiltInKotlinFalse = project.hasProperty('builtInKotlin') && (project.property('builtInKotlin') == false || project.property('builtInKotlin') == 'false')
|
||||
if (agpMajor < 9 || isBuiltInKotlinFalse) {
|
||||
apply plugin: ['kotlin', 'android'].join('-')
|
||||
}
|
||||
|
||||
group = 'com.bbflight.background_downloader'
|
||||
version = '1.0-SNAPSHOT'
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = 36
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 23
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
if (project.android.hasProperty("namespace")) { namespace = 'com.bbflight.background_downloader' }
|
||||
|
||||
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "androidx.work:work-runtime-ktx:2.11.0"
|
||||
implementation "androidx.concurrent:concurrent-futures-ktx:1.3.0"
|
||||
implementation "androidx.preference:preference-ktx:1.2.1"
|
||||
implementation "androidx.core:core-ktx:1.17.0"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0"
|
||||
testImplementation "junit:junit:4.13.2"
|
||||
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2"
|
||||
androidTestImplementation "androidx.test.ext:junit:1.3.0"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:3.7.0"
|
||||
|
||||
}
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
## For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
#
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
# Default value: -Xmx1024m -XX:MaxPermSize=256m
|
||||
# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
||||
#
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. For more details, visit
|
||||
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
|
||||
# org.gradle.parallel=true
|
||||
#Fri Dec 19 20:02:00 PST 2025
|
||||
android.enableJetifier=true
|
||||
android.useAndroidX=true
|
||||
@@ -0,0 +1,13 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
plugins {
|
||||
id 'com.android.library' version '9.0.1'
|
||||
id 'org.jetbrains.kotlin.android' version '2.3.20'
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = 'background_downloader'
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import android.net.Uri
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.bbflight.background_downloader.UriUtils
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class UriUtilsTest {
|
||||
|
||||
@Test
|
||||
fun pack_should_pack_filename_and_uri_into_a_single_string() {
|
||||
val filename = "myFile.txt"
|
||||
val uri = Uri.parse("content://com.example.app/document/123")
|
||||
|
||||
val packedString = UriUtils.pack(filename, uri)
|
||||
|
||||
assertEquals(":::$filename::::::$uri:::", packedString)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unpack_should_unpack_a_valid_packed_string_into_filename_and_uri() {
|
||||
val filename = "myFile.txt"
|
||||
val uri = Uri.parse("content://com.example.app/document/123")
|
||||
val packedString = ":::$filename::::::$uri:::"
|
||||
|
||||
val (unpackedFilename, unpackedUri) = UriUtils.unpack(packedString)
|
||||
|
||||
assertEquals(filename, unpackedFilename)
|
||||
assertEquals(uri, unpackedUri)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unpack_should_return_original_string_and_null_uri_for_simple_filename_string() {
|
||||
val invalidPackedString = "This is not a packed string"
|
||||
|
||||
val (filename, uri) = UriUtils.unpack(invalidPackedString)
|
||||
|
||||
assertEquals(invalidPackedString, filename)
|
||||
assertNull(uri)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unpack_should_return_null_and_uri_for_simple_uri_string() {
|
||||
val uriString = "https://www.example.com/path/to/resource"
|
||||
|
||||
val (filename, uri) = UriUtils.unpack(uriString)
|
||||
|
||||
assertNull(filename)
|
||||
assertEquals(uri.toString(), uriString)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uriFromStringValue_should_return_Uri_for_a_valid_Uri_string() {
|
||||
val uriString = "https://www.example.com/path/to/resource"
|
||||
val expectedUri = Uri.parse(uriString)
|
||||
|
||||
val resultUri = UriUtils.uriFromStringValue(uriString)
|
||||
|
||||
assertEquals(expectedUri, resultUri)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uriFromStringValue_should_return_Uri_from_a_valid_packed_string() {
|
||||
val filename = "myFile.txt"
|
||||
val uri = Uri.parse("content://com.example.app/document/123")
|
||||
val packedString = UriUtils.pack(filename, uri)
|
||||
|
||||
val resultUri = UriUtils.uriFromStringValue(packedString)
|
||||
|
||||
assertEquals(uri, resultUri)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uriFromStringValue_should_return_null_for_an_invalid_string() {
|
||||
val invalidString = "This is not a Uri or packed string"
|
||||
|
||||
val resultUri = UriUtils.uriFromStringValue(invalidString)
|
||||
|
||||
assertNull(resultUri)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uriFromStringValue_should_return_null_for_a_packed_string_with_invalid_Uri() {
|
||||
val filename = "myFile.txt"
|
||||
val invalidUri = "invalid"
|
||||
val packedString = ":::$filename::::::$invalidUri:::"
|
||||
|
||||
val resultUri = UriUtils.uriFromStringValue(packedString)
|
||||
|
||||
assertNull(resultUri)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun containsUri_should_return_true_for_a_valid_Uri_string() {
|
||||
val uriString = "https://www.example.com/path/to/resource"
|
||||
assertTrue(UriUtils.containsUri(uriString))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun containsUri_should_return_true_for_a_valid_packed_Uri_string() {
|
||||
val filename = "myFile.txt"
|
||||
val uri = Uri.parse("content://com.example.app/document/123")
|
||||
val packedString = UriUtils.pack(filename, uri)
|
||||
|
||||
assertTrue(UriUtils.containsUri(packedString))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun containsUri_should_return_false_for_an_invalid_string() {
|
||||
val invalidString = "This is not a Uri or packed string"
|
||||
|
||||
assertFalse(UriUtils.containsUri(invalidString))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<manifest xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
|
||||
<receiver android:name="NotificationReceiver" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.bbflight.background_downloader.cancelActive" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="com.bbflight.background_downloader.pause" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="com.bbflight.background_downloader.resume" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<provider
|
||||
android:name="com.bbflight.background_downloader.OpenFileProvider"
|
||||
android:authorities="${applicationId}.com.bbflight.background_downloader.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true"
|
||||
tools:replace="android:authorities">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/bgd_file_paths" />
|
||||
</provider>
|
||||
|
||||
<service
|
||||
android:name=".UIDTJobService"
|
||||
android:permission="android.permission.BIND_JOB_SERVICE"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/Auth.kt
Vendored
+94
@@ -0,0 +1,94 @@
|
||||
package com.bbflight.background_downloader
|
||||
|
||||
import android.net.Uri
|
||||
import kotlinx.serialization.InternalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@Suppress("unused")
|
||||
@OptIn(InternalSerializationApi::class)
|
||||
class Auth(
|
||||
private var accessToken: String? = null,
|
||||
private var accessHeaders: Map<String, String> = emptyMap(),
|
||||
private var accessQueryParams: Map<String, String> = emptyMap(),
|
||||
private var accessTokenExpiryTime: Long? = null,
|
||||
private var refreshToken: String? = null,
|
||||
private var refreshHeaders: Map<String, String> = emptyMap(),
|
||||
private var refreshQueryParams: Map<String, String> = emptyMap(),
|
||||
private var refreshUrl: String? = null,
|
||||
private var onAuthRawHandle: Long? = null
|
||||
) {
|
||||
|
||||
/**
|
||||
* Returns the headers specified for the access request, with the
|
||||
* template `{accessToken}` and `{refreshToken}` replaced.
|
||||
*/
|
||||
fun getExpandedAccessHeaders(): Map<String, String> = expandMap(accessHeaders)
|
||||
|
||||
/**
|
||||
* Returns the query parameters specified for the access request, with the
|
||||
* template `{accessToken}` and `{refreshToken}` replaced.
|
||||
*/
|
||||
fun getExpandedAccessQueryParams(): Map<String, String> = expandMap(accessQueryParams)
|
||||
|
||||
/**
|
||||
* Add/update the query parameters in this [url] with [queryParams]
|
||||
* and return the new uri
|
||||
*/
|
||||
fun addOrUpdateQueryParams(
|
||||
url: String,
|
||||
queryParams: Map<String, String> = emptyMap()
|
||||
): Uri {
|
||||
val startUri = Uri.parse(url)
|
||||
if (queryParams.isEmpty()) {
|
||||
return startUri
|
||||
}
|
||||
val updatedQueryParams =
|
||||
startUri.queryParameterNames.associateWith { startUri.getQueryParameter(it) }
|
||||
.toMutableMap()
|
||||
updatedQueryParams.putAll(queryParams)
|
||||
return startUri.buildUpon().clearQuery().apply {
|
||||
updatedQueryParams.forEach { (key, value) ->
|
||||
appendQueryParameter(key, value)
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns true if the [accessTokenExpiryTime is after now plus
|
||||
* the [bufferTime], otherwise returns false
|
||||
*/
|
||||
fun isTokenExpired(bufferTime: Long = 10000): Boolean {
|
||||
val expiry = accessTokenExpiryTime ?: return false
|
||||
val expiryTimeWithBuffer = System.currentTimeMillis() + bufferTime
|
||||
return expiryTimeWithBuffer > expiry
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands the [mapToExpand] by replacing {accessToken} and {refreshToken}
|
||||
*
|
||||
* Returns the expanded map, without changing the original
|
||||
*/
|
||||
private fun expandMap(mapToExpand: Map<String, String>): Map<String, String> {
|
||||
val newMap = mutableMapOf<String, String>()
|
||||
mapToExpand.forEach { (key, value) ->
|
||||
var newValue = value
|
||||
if (accessToken != null) {
|
||||
newValue = newValue.replace("{accessToken}", accessToken!!)
|
||||
}
|
||||
if (refreshToken != null) {
|
||||
newValue = newValue.replace("{refreshToken}", refreshToken!!)
|
||||
}
|
||||
newMap[key] = newValue
|
||||
}
|
||||
return newMap
|
||||
}
|
||||
|
||||
/**
|
||||
* True if this Auth object has an 'onAuth' callback that can be called to refresh the
|
||||
* access token
|
||||
*/
|
||||
fun hasOnAuthCallback(): Boolean = onAuthRawHandle != null
|
||||
|
||||
}
|
||||
+1770
File diff suppressed because it is too large.
Load diff
+199
@@ -0,0 +1,199 @@
|
||||
package com.bbflight.background_downloader
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.preference.PreferenceManager
|
||||
import io.flutter.FlutterInjector
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.dart.DartExecutor
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.view.FlutterCallbackInformation
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
|
||||
class Callbacks {
|
||||
companion object {
|
||||
const val TAG = "Callbacks"
|
||||
private var callbackMethodChannel: MethodChannel? = null
|
||||
private val methodChannelMutex = Mutex()
|
||||
|
||||
/**
|
||||
* Retrieves the [MethodChannel] used for communication with the Flutter background isolate.
|
||||
*
|
||||
* This method initializes the `MethodChannel` if it hasn't been created yet. It does so by:
|
||||
* 1. Obtaining the `callbackDispatcherRawHandle` from shared preferences.
|
||||
* 2. Creating a new [FlutterEngine] and executing the Dart callback dispatcher.
|
||||
* 3. Creating a [MethodChannel] using the engine's binary messenger.
|
||||
*
|
||||
* The initialization process happens on the UI thread to avoid potential issues.
|
||||
*
|
||||
* This method is thread-safe due to the use of a mutex.
|
||||
*
|
||||
* @param context The [Context] required for accessing resources and the UI thread.
|
||||
* @return The [MethodChannel] instance, or `null` if initialization fails.
|
||||
*/
|
||||
private suspend fun getMethodChannel(context: Context): MethodChannel? {
|
||||
methodChannelMutex.withLock {
|
||||
if (callbackMethodChannel == null) {
|
||||
// create new flutterEngine and invoke callbackDispatcher, on the UI thread
|
||||
val methodChannelCompleter = CompletableDeferred<MethodChannel?>()
|
||||
Handler(Looper.getMainLooper()).post { // Run on UI thread
|
||||
try {
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
val rawHandle =
|
||||
prefs.getLong(BDPlugin.keyCallbackDispatcherRawHandle, -1L)
|
||||
if (rawHandle == -1L) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"getMethodChannel without registered callbackDispatcherRawHandle"
|
||||
)
|
||||
methodChannelCompleter.complete(null)
|
||||
return@post
|
||||
}
|
||||
val flutterEngine = FlutterEngine(context, null, false)
|
||||
val callbackDispatcherCallback =
|
||||
FlutterCallbackInformation.lookupCallbackInformation(rawHandle)
|
||||
if (callbackDispatcherCallback == null) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"invokeOnStartCallback failed to find callbackDispatcher"
|
||||
)
|
||||
methodChannelCompleter.complete(null)
|
||||
return@post
|
||||
}
|
||||
val appBundlePath: String =
|
||||
FlutterInjector.instance().flutterLoader().findAppBundlePath()
|
||||
val assets = context.assets
|
||||
flutterEngine.dartExecutor.executeDartCallback(
|
||||
DartExecutor.DartCallback(
|
||||
assets,
|
||||
appBundlePath,
|
||||
callbackDispatcherCallback
|
||||
)
|
||||
)
|
||||
callbackMethodChannel = MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
"com.bbflight.background_downloader.callbacks"
|
||||
)
|
||||
methodChannelCompleter.complete(callbackMethodChannel)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error creating MethodChannel", e)
|
||||
methodChannelCompleter.complete(null)
|
||||
}
|
||||
}
|
||||
return methodChannelCompleter.await() // Wait for the UI thread to finish
|
||||
}
|
||||
return callbackMethodChannel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes a callback method on the Flutter side using the specified method channel.
|
||||
*
|
||||
* This function handles the asynchronous communication with Flutter, ensuring thread safety
|
||||
* and proper handling of the result. It serializes the given `task` object to JSON
|
||||
* and passes it as an argument to the Flutter method.
|
||||
*
|
||||
* @param context The Android context.
|
||||
* @param task The [Task] object to be passed to the Flutter callback, or null.
|
||||
* @param statusUpdate The [TaskStatusUpdate] object to be passed to the Flutter callback, or null.
|
||||
* @param methodName The name of the method to invoke on the Flutter side.
|
||||
* @return The updated object returned by the Flutter callback, or `null` if
|
||||
* the task is unchanged, an error occurs, or the method channel is not available.
|
||||
*/
|
||||
private suspend fun invokeCallback(
|
||||
context: Context,
|
||||
methodName: String,
|
||||
task: Task? = null,
|
||||
statusUpdate: TaskStatusUpdate? = null,
|
||||
): Any? {
|
||||
val methodChannel = getMethodChannel(context) ?: return null
|
||||
methodChannelMutex.withLock {
|
||||
val resultingObjectAsJsonStringCompleter = CompletableDeferred<String?>()
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
// Run on UI thread
|
||||
val arg = if (task != null) bdJson.encodeToString(task) else bdJson.encodeToString(
|
||||
statusUpdate
|
||||
)
|
||||
Log.i(
|
||||
TAG,
|
||||
"Invoking $methodName for taskId ${task?.taskId}"
|
||||
)
|
||||
methodChannel.invokeMethod(
|
||||
methodName,
|
||||
arg, // either task or update
|
||||
FlutterResultHandler(resultingObjectAsJsonStringCompleter)
|
||||
)
|
||||
}
|
||||
val objectAsJsonString = resultingObjectAsJsonStringCompleter.await()
|
||||
return if (objectAsJsonString == null) null else
|
||||
if (methodName == "beforeTaskStartCallback") bdJson.decodeFromString<TaskStatusUpdate>(
|
||||
objectAsJsonString
|
||||
) else bdJson.decodeFromString<Task>(
|
||||
objectAsJsonString
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke the beforeTaskStartCallback and return the result
|
||||
*/
|
||||
suspend fun invokeBeforeTaskStartCallback(context: Context, task: Task): TaskStatusUpdate? {
|
||||
try {
|
||||
return invokeCallback(
|
||||
context,
|
||||
"beforeTaskStartCallback",
|
||||
task = task
|
||||
) as TaskStatusUpdate?
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error in invokeBeforeTaskStartCallback", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke the onTaskStartCallback and return the result
|
||||
*/
|
||||
suspend fun invokeOnTaskStartCallback(context: Context, task: Task): Task? {
|
||||
try {
|
||||
return invokeCallback(context, "onTaskStartCallback", task = task) as Task?
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error in invokeOnTaskStartCallback", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke the invokeOnAuthCallback and return the result
|
||||
*/
|
||||
suspend fun invokeOnAuthCallback(context: Context, task: Task): Task? {
|
||||
try {
|
||||
return invokeCallback(context, "onAuthCallback", task = task) as Task?
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error in invokeOnAuthCallback", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke the onTaskFinishedCallback
|
||||
*/
|
||||
suspend fun invokeOnTaskFinishedCallback(context: Context, statusUpdate: TaskStatusUpdate) {
|
||||
try {
|
||||
invokeCallback(
|
||||
context,
|
||||
"onTaskFinishedCallback",
|
||||
statusUpdate = statusUpdate
|
||||
) as Task?
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error in invokeOnTaskFinishedCallback", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.bbflight.background_downloader
|
||||
|
||||
import android.util.Log
|
||||
import java.net.HttpURLConnection
|
||||
|
||||
class DataTaskRunner(context: TaskJobContext) : TaskRunner(context) {
|
||||
|
||||
/** Process the response to the GET or POST request on this [connection]
|
||||
*
|
||||
* Returns the [TaskStatus]
|
||||
*/
|
||||
override suspend fun process(
|
||||
connection: HttpURLConnection,
|
||||
): TaskStatus {
|
||||
responseStatusCode = connection.responseCode
|
||||
if (responseStatusCode!! in 200..206) {
|
||||
extractResponseHeaders(connection.headerFields)
|
||||
extractContentType(connection.headerFields)
|
||||
// transfer the bytes from the server to the temp file
|
||||
return try {
|
||||
responseBody = connection.inputStream.bufferedReader().readText()
|
||||
TaskStatus.complete
|
||||
} catch (e: Exception) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Could not read response content: $e"
|
||||
)
|
||||
taskException = TaskException(ExceptionType.connection, description = "Could not read response content: $e")
|
||||
TaskStatus.failed
|
||||
}
|
||||
} else {
|
||||
// HTTP response code not OK
|
||||
Log.i(
|
||||
TAG,
|
||||
"Response code ${connection.responseCode} for taskId ${task.taskId}"
|
||||
)
|
||||
val errorContent = responseErrorContent(connection)
|
||||
taskException = TaskException(
|
||||
ExceptionType.httpResponse, httpResponseCode = connection.responseCode,
|
||||
description = if (errorContent?.isNotEmpty() == true) errorContent else connection.responseMessage
|
||||
)
|
||||
return if (responseStatusCode == 404) {
|
||||
responseBody = errorContent
|
||||
TaskStatus.notFound
|
||||
} else {
|
||||
TaskStatus.failed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.bbflight.background_downloader
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.WorkerParameters
|
||||
|
||||
class DataTaskWorker(context: Context, params: WorkerParameters) :
|
||||
TaskWorker(context, params) {
|
||||
|
||||
override fun createRunner(): TaskRunner {
|
||||
return DataTaskRunner(this)
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.bbflight.background_downloader;
|
||||
|
||||
import android.net.NetworkCapabilities;
|
||||
import android.net.NetworkRequest;
|
||||
|
||||
/** Shared by WorkManager and user-initiated jobs on Android 9 and later. */
|
||||
public final class DownloadNetworkPolicy {
|
||||
private DownloadNetworkPolicy() {}
|
||||
|
||||
public static NetworkRequest request(boolean wifiOnly) {
|
||||
// Media may be reachable even when an OS connectivity-check host is
|
||||
// blocked. Require internet capability, not OS network validation.
|
||||
NetworkRequest.Builder builder = new NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
.removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN);
|
||||
if (wifiOnly) {
|
||||
builder.addTransportType(NetworkCapabilities.TRANSPORT_WIFI);
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
}
|
||||
+662
@@ -0,0 +1,662 @@
|
||||
package com.bbflight.background_downloader
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.storage.StorageManager
|
||||
import android.util.Log
|
||||
import androidx.core.net.toFile
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.bbflight.background_downloader.UriUtils.unpack
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.RandomAccessFile
|
||||
import java.net.HttpURLConnection
|
||||
import java.nio.channels.FileChannel
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.nio.file.StandardOpenOption
|
||||
import android.system.Os
|
||||
import android.system.ErrnoException
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.random.Random
|
||||
|
||||
class DownloadTaskRunner(context: TaskJobContext) : TaskRunner(context) {
|
||||
|
||||
private var eTagHeader: String? = null
|
||||
private var serverAcceptsRanges = false // if true, send resume data on fail
|
||||
private var tempFilePath = ""
|
||||
private var requiredStartByte = 0L // required start byte within the task range
|
||||
private var taskRangeStartByte = 0L // Start of the Task's download range
|
||||
private var eTag: String? = null
|
||||
|
||||
/**
|
||||
* Make the request to the [connection] and process the [Task]
|
||||
*
|
||||
* Returns the [TaskStatus]
|
||||
* */
|
||||
override suspend fun connectAndProcess(connection: HttpURLConnection): TaskStatus {
|
||||
if (isResume) {
|
||||
val taskRangeHeader = task.headers["Range"] ?: ""
|
||||
val taskRange = parseRange(taskRangeHeader)
|
||||
taskRangeStartByte = taskRange.first
|
||||
val resumeRange = Pair(taskRangeStartByte + requiredStartByte, taskRange.second)
|
||||
val newRangeString = "bytes=${resumeRange.first}-${resumeRange.second ?: ""}"
|
||||
connection.setRequestProperty("Range", newRangeString)
|
||||
}
|
||||
val result = super.connectAndProcess(connection)
|
||||
if (result == TaskStatus.canceled) {
|
||||
deleteTempFile()
|
||||
}
|
||||
if (result == TaskStatus.failed) {
|
||||
prepResumeAfterFailure()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Process the response to the GET or POST request on this [connection]
|
||||
*
|
||||
* Returns the [TaskStatus]
|
||||
*/
|
||||
override suspend fun process(
|
||||
connection: HttpURLConnection,
|
||||
): TaskStatus {
|
||||
// Check if the file should be skipped
|
||||
// (not possible if the filename will be suggested by the server)
|
||||
val skipThreshold = prefs.getInt(BDPlugin.keyConfigSkipExistingFiles, -1)
|
||||
if (skipThreshold != -1 && task.filename != "?") {
|
||||
val filePath = task.filePath(context.appContext)
|
||||
val file = File(filePath)
|
||||
if (file.exists()) {
|
||||
val fileSize = file.length()
|
||||
// threshold 0 skips any existing file, including an empty one
|
||||
if (skipThreshold == 0 || fileSize > skipThreshold * 1024L * 1024L) {
|
||||
responseStatusCode = 304
|
||||
return TaskStatus.complete
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
responseStatusCode = connection.responseCode
|
||||
if (connection.responseCode in 200..206) {
|
||||
// determine if we are using Uri or not. Uri means pause/resume not allowed
|
||||
val directoryUri = UriUtils.uriFromStringValue(task.directory)
|
||||
val usesUri = directoryUri != null
|
||||
eTagHeader = connection.headerFields["ETag"]?.first()
|
||||
val acceptRangesHeader = connection.headerFields["Accept-Ranges"]
|
||||
serverAcceptsRanges =
|
||||
acceptRangesHeader?.first() == "bytes" || connection.responseCode == 206
|
||||
if (task.allowPause && !usesUri) {
|
||||
taskCanResume = serverAcceptsRanges
|
||||
processCanResume(
|
||||
task,
|
||||
taskCanResume
|
||||
)
|
||||
}
|
||||
isResume = isResume && connection.responseCode == 206 // confirm resume response
|
||||
if (isResume && !prepareResume(connection)) {
|
||||
deleteTempFile()
|
||||
return TaskStatus.failed
|
||||
}
|
||||
if (isResume && (eTagHeader != eTag || eTag?.startsWith("W/") == true)) {
|
||||
deleteTempFile()
|
||||
Log.i(TAG, "Cannot resume: ETag is not identical, or is weak")
|
||||
taskException = TaskException(
|
||||
ExceptionType.resume,
|
||||
description = "Cannot resume: ETag is not identical, or is weak"
|
||||
)
|
||||
return TaskStatus.failed
|
||||
}
|
||||
// Determine destination - either [destFilePath] or [destUri]
|
||||
// If no filename is set, get from headers or url, and update the task
|
||||
var destFilePath = task.filePath(context.appContext)
|
||||
var (uriFilename, destUri) = unpack(task.filename)
|
||||
if (!task.hasFilename()) {
|
||||
// If no filename is set, get from headers or url, and update the task
|
||||
if (usesUri) {
|
||||
uriFilename = suggestFilename(connection.headerFields, task.url)
|
||||
if (uriFilename.isEmpty()) {
|
||||
uriFilename = "${Random.nextInt().absoluteValue}"
|
||||
}
|
||||
task = task.copyWith(
|
||||
filename = if (destUri == null) uriFilename else UriUtils.pack(
|
||||
uriFilename,
|
||||
destUri
|
||||
)
|
||||
)
|
||||
} else {
|
||||
destFilePath = destFilePath(connection)
|
||||
}
|
||||
}
|
||||
extractResponseHeaders(connection.headerFields)
|
||||
extractContentType(connection.headerFields)
|
||||
val contentLength = getContentLength(connection.headerFields, task)
|
||||
// determine tempFile, or set to null if Uri is used
|
||||
val tempFile = if (!usesUri) {
|
||||
val applicationSupportPath =
|
||||
baseDirPath(context.appContext, BaseDirectory.applicationSupport)
|
||||
val cachePath = baseDirPath(context.appContext, BaseDirectory.temporary)
|
||||
if (applicationSupportPath == null || cachePath == null) {
|
||||
throw IllegalStateException("External storage is requested but not available")
|
||||
}
|
||||
val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context.appContext)
|
||||
val tempFilePathConfig = sharedPrefs.getString(BDPlugin.keyConfigTempFilePath, null)
|
||||
val tempDir = if (!tempFilePathConfig.isNullOrEmpty()) {
|
||||
File(tempFilePathConfig)
|
||||
} else {
|
||||
when (sharedPrefs.getInt(BDPlugin.keyConfigUseCacheDir, -2)) {
|
||||
0 -> File(cachePath) // 'always'
|
||||
-1 -> File(applicationSupportPath) // 'never'
|
||||
else -> {
|
||||
// 'whenAble' -> determine based on cache quota
|
||||
val storageManager =
|
||||
context.appContext.getSystemService(Context.STORAGE_SERVICE) as StorageManager
|
||||
val cacheQuota = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
storageManager.getCacheQuotaBytes(
|
||||
storageManager.getUuidForPath(
|
||||
File(cachePath)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
50L shl (20) // for older OS versions, assume 50MB
|
||||
}
|
||||
if (contentLength < cacheQuota / 2) File(cachePath) else File(
|
||||
applicationSupportPath
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!tempDir.exists()) {
|
||||
tempDir.mkdirs()
|
||||
}
|
||||
tempFilePath =
|
||||
tempFilePath.ifEmpty { "${tempDir.absolutePath}/com.bbflight.background_downloader${Random.nextInt()}" }
|
||||
|
||||
// confirm enough storage space for download
|
||||
if (insufficientSpace(context.appContext, contentLength)) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Insufficient space to store the file to be downloaded for taskId ${task.taskId}"
|
||||
)
|
||||
taskException = TaskException(
|
||||
ExceptionType.fileSystem,
|
||||
description = "Insufficient space to store the file to be downloaded"
|
||||
)
|
||||
return TaskStatus.failed
|
||||
}
|
||||
File(tempFilePath)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val outputStream: java.io.OutputStream = if (tempFile != null) {
|
||||
FileOutputStream(tempFile, isResume)
|
||||
} else {
|
||||
// no tempFile, because we have a Uri
|
||||
uriFilename = uriFilename ?: "unknown"
|
||||
if (directoryUri!!.scheme == "file") {
|
||||
// fileUri is converted to a File, then to a FileOutputStream
|
||||
if (destUri == null) {
|
||||
// need to create file at directory
|
||||
val dirObject = directoryUri.toFile()
|
||||
val destFile = File(dirObject, uriFilename)
|
||||
destUri = Uri.fromFile(destFile)
|
||||
// Store destination Uri in task
|
||||
task = task.copyWith(filename = UriUtils.pack(uriFilename, destUri))
|
||||
FileOutputStream(destFile) // return outputStream
|
||||
} else {
|
||||
// use destination Uri that was set in previous attempt
|
||||
FileOutputStream(destUri.toFile())
|
||||
}
|
||||
} else {
|
||||
// other URL scheme will be attempted to resolve using content resolver
|
||||
val resolver = context.appContext.contentResolver
|
||||
// create destination Uri if not already exists
|
||||
val documentFile = DocumentFile.fromTreeUri(context.appContext, directoryUri)
|
||||
destUri = destUri ?: documentFile?.createFile(task.mimeType, uriFilename)?.uri
|
||||
if (destUri == null) {
|
||||
val message =
|
||||
"Failed to create document within directory with URI: $directoryUri"
|
||||
Log.e(TAG, message)
|
||||
taskException = TaskException(
|
||||
ExceptionType.fileSystem,
|
||||
description = message
|
||||
)
|
||||
return TaskStatus.failed
|
||||
}
|
||||
val newFilename = getFilenameFromUri(destUri)
|
||||
if (newFilename.isNotEmpty()) {
|
||||
uriFilename = newFilename
|
||||
}
|
||||
task = task.copyWith(filename = UriUtils.pack(uriFilename, destUri))
|
||||
val os = resolver.openOutputStream(destUri)
|
||||
if (os == null) {
|
||||
val message = "Failed to open output stream for URI: $destUri"
|
||||
Log.e(TAG, message)
|
||||
taskException = TaskException(
|
||||
ExceptionType.fileSystem,
|
||||
description = message
|
||||
)
|
||||
return TaskStatus.failed
|
||||
} else os
|
||||
}
|
||||
}
|
||||
BDPlugin.remainingBytesToDownload[task.taskId] = contentLength
|
||||
determineRunInForeground(task, contentLength) // sets 'runInForeground'
|
||||
context.updateEstimatedNetworkBytes(contentLength, 0L)
|
||||
// transfer the bytes from the server to the output stream
|
||||
val transferBytesResult: TaskStatus
|
||||
outputStream.use { os ->
|
||||
BufferedInputStream(connection.inputStream).use { inputStream ->
|
||||
transferBytesResult = transferBytes(
|
||||
inputStream, os, contentLength, task
|
||||
)
|
||||
}
|
||||
os.flush()
|
||||
}
|
||||
// act on the result of the bytes transfer
|
||||
when (transferBytesResult) {
|
||||
TaskStatus.complete -> {
|
||||
if (tempFile == null) {
|
||||
Log.i(
|
||||
TAG, "Successfully downloaded taskId ${task.taskId} to URI $destUri"
|
||||
)
|
||||
} else {
|
||||
// move file from its temp location to the destination
|
||||
val destFile = File(destFilePath)
|
||||
val dir = destFile.parentFile!!
|
||||
if (!dir.exists()) {
|
||||
dir.mkdirs()
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Files.move(
|
||||
tempFile.toPath(),
|
||||
destFile.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING
|
||||
)
|
||||
} catch (e: java.nio.file.FileSystemException) {
|
||||
// Fallback if moving across different filesystems/mount points (EXDEV / Cross-device link)
|
||||
Log.d(TAG, "Files.move failed ($e), falling back to copyTo + delete")
|
||||
tempFile.copyTo(destFile, overwrite = true)
|
||||
deleteTempFile()
|
||||
}
|
||||
}
|
||||
setFileOwnership(destFile)
|
||||
} else {
|
||||
tempFile.copyTo(destFile, overwrite = true)
|
||||
deleteTempFile()
|
||||
setFileOwnership(destFile)
|
||||
}
|
||||
Log.i(
|
||||
TAG, "Successfully downloaded taskId ${task.taskId} to $destFilePath"
|
||||
)
|
||||
}
|
||||
return TaskStatus.complete
|
||||
}
|
||||
|
||||
TaskStatus.canceled -> {
|
||||
cleanup(usesUri, destUri)
|
||||
Log.i(TAG, "Canceled taskId ${task.taskId}")
|
||||
return TaskStatus.canceled
|
||||
}
|
||||
|
||||
TaskStatus.paused -> return pauseResult(usesUri, destUri, bytesTotal + startByte)
|
||||
|
||||
TaskStatus.enqueued -> {
|
||||
// Special status, in this context means that the task timed out
|
||||
return timeoutResult(usesUri, destUri, bytesTotal + startByte)
|
||||
}
|
||||
|
||||
TaskStatus.failed -> {
|
||||
// resume after failure is prepared in [connectAndProcess]
|
||||
return TaskStatus.failed
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.e(TAG, "Unknown transferBytesResult $transferBytesResult")
|
||||
cleanup(usesUri, destUri)
|
||||
return TaskStatus.failed
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// HTTP response code not OK
|
||||
Log.i(
|
||||
TAG,
|
||||
"Response code ${connection.responseCode} for taskId ${task.taskId}"
|
||||
)
|
||||
val errorContent = responseErrorContent(connection)
|
||||
taskException = TaskException(
|
||||
ExceptionType.httpResponse, httpResponseCode = connection.responseCode,
|
||||
description = if (errorContent?.isNotEmpty() == true) errorContent else connection.responseMessage
|
||||
)
|
||||
return if (connection.responseCode == 404) {
|
||||
responseBody = errorContent
|
||||
TaskStatus.notFound
|
||||
} else {
|
||||
TaskStatus.failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the final [TaskStatus] for a task that was paused, with [resumeStart]
|
||||
* the number of bytes in the temp file.
|
||||
*
|
||||
* Posts resume data if the task can resume, otherwise fails the task
|
||||
*/
|
||||
private suspend fun pauseResult(usesUri: Boolean, destUri: Uri?, resumeStart: Long): TaskStatus {
|
||||
BDPlugin.pausedTaskIds.remove(task.taskId)
|
||||
if (taskCanResume) {
|
||||
Log.i(TAG, "Task ${task.taskId} paused")
|
||||
processResumeData(
|
||||
ResumeData(
|
||||
task, tempFilePath, resumeStart, eTagHeader
|
||||
), prefs
|
||||
)
|
||||
return TaskStatus.paused
|
||||
}
|
||||
if (BDPlugin.tasksToReEnqueue.contains(task) && serverAcceptsRanges) {
|
||||
// pause was triggered by re-enqueue request due to WiFi requirement change
|
||||
// so we only store local resumeData without posting it
|
||||
Log.i(TAG, "Task ${task.taskId} paused in order to re-enqueue")
|
||||
BDPlugin.localResumeData[task.taskId] = ResumeData(
|
||||
task, tempFilePath, resumeStart, eTagHeader
|
||||
)
|
||||
return TaskStatus.paused
|
||||
}
|
||||
Log.i(TAG, "Task ${task.taskId} cannot resume, therefore pause failed")
|
||||
taskException = TaskException(
|
||||
ExceptionType.resume,
|
||||
description = "Task was paused but cannot resume"
|
||||
)
|
||||
cleanup(usesUri, destUri)
|
||||
return TaskStatus.failed
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the final [TaskStatus] for a task that timed out, with [resumeStart]
|
||||
* the number of bytes in the temp file.
|
||||
*
|
||||
* If allowed, pauses the task and schedules the resume task immediately,
|
||||
* otherwise fails the task. Never returns [TaskStatus.enqueued]
|
||||
*/
|
||||
private suspend fun timeoutResult(usesUri: Boolean, destUri: Uri?, resumeStart: Long): TaskStatus {
|
||||
if (!task.allowPause) {
|
||||
Log.i(TAG, "Task ${task.taskId} timed out")
|
||||
taskException =
|
||||
TaskException(
|
||||
ExceptionType.connection,
|
||||
description = "Task timed out"
|
||||
)
|
||||
return TaskStatus.failed
|
||||
}
|
||||
if (taskCanResume) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Task ${task.taskId} paused due to timeout, will resume in 1 second"
|
||||
)
|
||||
val success = BDPlugin.doEnqueue(
|
||||
context.appContext,
|
||||
task,
|
||||
notificationConfigJsonString,
|
||||
ResumeData(task, tempFilePath, resumeStart, eTagHeader),
|
||||
1000
|
||||
)
|
||||
if (!success) {
|
||||
Log.w(TAG, "Task ${task.taskId} timed out and could not re-enqueue to resume")
|
||||
taskException = TaskException(
|
||||
ExceptionType.general,
|
||||
description = "Task timed out and could not re-enqueue to resume"
|
||||
)
|
||||
cleanup(usesUri, destUri)
|
||||
return TaskStatus.failed
|
||||
}
|
||||
return TaskStatus.paused
|
||||
}
|
||||
Log.i(TAG, "Task ${task.taskId} timed out and cannot pause/resume")
|
||||
taskException =
|
||||
TaskException(ExceptionType.connection, description = "Task timed out")
|
||||
cleanup(usesUri, destUri)
|
||||
return TaskStatus.failed
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause interrupted the task outside of [transferBytes], e.g. while waiting
|
||||
* for the response. Resume from whatever is in the temp file, if possible
|
||||
*/
|
||||
override suspend fun handlePause(): TaskStatus {
|
||||
prepareInterruptedResume()
|
||||
return pauseResult(usesUriDestination(), unpack(task.filename).second, tempFileLength())
|
||||
}
|
||||
|
||||
/**
|
||||
* Timeout interrupted the task outside of [transferBytes], e.g. while
|
||||
* waiting for the response. Resume from whatever is in the temp file, if possible
|
||||
*/
|
||||
override suspend fun handleTimeout(): TaskStatus {
|
||||
prepareInterruptedResume()
|
||||
return timeoutResult(usesUriDestination(), unpack(task.filename).second, tempFileLength())
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare state for [handlePause] and [handleTimeout]: resume is only
|
||||
* possible if the temp file holds data, and if the task already
|
||||
* established that it can resume (in this or in a previous run)
|
||||
*/
|
||||
private fun prepareInterruptedResume() {
|
||||
val hasTempData = !usesUriDestination() && tempFileLength() > 0
|
||||
taskCanResume = task.allowPause && hasTempData && (taskCanResume || isResume)
|
||||
eTagHeader = eTagHeader ?: eTag
|
||||
}
|
||||
|
||||
/** True if the destination of this task is a Uri */
|
||||
private fun usesUriDestination() = UriUtils.uriFromStringValue(task.directory) != null
|
||||
|
||||
/** Length of the temp file, or 0 if it does not exist */
|
||||
private fun tempFileLength(): Long {
|
||||
if (tempFilePath.isEmpty()) return 0
|
||||
val tempFile = File(tempFilePath)
|
||||
return if (tempFile.exists()) tempFile.length() else 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Return destination filePath where the filename is set based on response headers, and
|
||||
* update the [task] accordingly
|
||||
*/
|
||||
private fun destFilePath(connection: HttpURLConnection): String {
|
||||
val destFilePath = task.filePath(context.appContext)
|
||||
task = task.withSuggestedFilenameFromResponseHeaders(
|
||||
context.appContext,
|
||||
connection.headerFields,
|
||||
unique = true
|
||||
)
|
||||
val dirName = File(destFilePath).parent ?: ""
|
||||
return if (dirName.isEmpty()) task.filename else "$dirName/${task.filename}"
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Return true if this is a resume, and resume is possible,
|
||||
* given [tempFilePath] and [requiredStartByte]
|
||||
* */
|
||||
override fun determineIfResume(): Boolean {
|
||||
// set tempFilePath from resume data, or "" if a new tempFile is needed
|
||||
requiredStartByte = context.getInputLong(keyStartByte, 0)
|
||||
if (requiredStartByte == 0L) {
|
||||
return false
|
||||
}
|
||||
eTag = context.getInputString(keyETag)
|
||||
tempFilePath = if (requiredStartByte > 0) context.getInputString(keyResumeDataData) ?: ""
|
||||
else ""
|
||||
val tempFile = File(tempFilePath)
|
||||
if (tempFile.exists()) {
|
||||
val tempFileLength = tempFile.length()
|
||||
if (tempFileLength == requiredStartByte) {
|
||||
return true
|
||||
} else {
|
||||
// attempt to truncate the file to the expected size
|
||||
Log.d(
|
||||
TAG,
|
||||
"File length = ${tempFile.length()} vs requiredStartByte = $requiredStartByte"
|
||||
)
|
||||
if (tempFileLength > requiredStartByte && Build.VERSION.SDK_INT >= 26) {
|
||||
try {
|
||||
val fileChannel =
|
||||
FileChannel.open(tempFile.toPath(), StandardOpenOption.WRITE)
|
||||
fileChannel.truncate(requiredStartByte)
|
||||
fileChannel.close()
|
||||
Log.d(TAG, "Truncated temp file to desired length")
|
||||
return true
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "Partially downloaded file is corrupted, resume not possible")
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "Partially downloaded file not available, resume not possible")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Prepare for resume if possible
|
||||
*
|
||||
* Returns true if task can continue, false if task failed.
|
||||
* Extracts and parses Range headers, and truncates temp file
|
||||
*/
|
||||
private fun prepareResume(connection: HttpURLConnection): Boolean {
|
||||
if (tempFilePath.isEmpty()) {
|
||||
return false
|
||||
}
|
||||
val contentRanges = connection.headerFields["Content-Range"]
|
||||
if (contentRanges == null || contentRanges.size > 1) {
|
||||
Log.i(
|
||||
TAG, "Could not " +
|
||||
"process partial response Content-Range"
|
||||
)
|
||||
return false
|
||||
}
|
||||
val range = contentRanges.first()
|
||||
val contentRangeRegEx = Regex("(\\d+)-(\\d+)/(\\d+)")
|
||||
val matchResult = contentRangeRegEx.find(range)
|
||||
if (matchResult == null) {
|
||||
Log.i(TAG, "Could not process partial response Content-Range $range")
|
||||
taskException = TaskException(
|
||||
ExceptionType.resume,
|
||||
description = "Could not process partial response Content-Range $range"
|
||||
)
|
||||
return false
|
||||
}
|
||||
val start = matchResult.groups[1]?.value?.toLong()!!
|
||||
val tempFile = File(tempFilePath)
|
||||
val tempFileLength = tempFile.length()
|
||||
startByte = start - taskRangeStartByte // relative to start of range
|
||||
if (startByte > tempFileLength) {
|
||||
Log.i(TAG, "Offered range not feasible: $range with startByte $startByte")
|
||||
taskException = TaskException(
|
||||
ExceptionType.resume,
|
||||
description = "Offered range not feasible: $range with startByte $startByte"
|
||||
)
|
||||
return false
|
||||
}
|
||||
// resume possible, set start conditions
|
||||
try {
|
||||
RandomAccessFile(tempFilePath, "rw").use { it.setLength(startByte) }
|
||||
} catch (_: IOException) {
|
||||
Log.i(TAG, "Could not truncate temp file")
|
||||
taskException =
|
||||
TaskException(
|
||||
ExceptionType.resume,
|
||||
description = "Could not truncate temp file"
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to allow resume after failure by sending resume data
|
||||
* back to Dart
|
||||
*
|
||||
* If this is not possible, the temp file will be deleted
|
||||
*/
|
||||
private suspend fun prepResumeAfterFailure() {
|
||||
// a Uri download has no temp file, and the temp file may have been deleted already
|
||||
val hasTempFile = tempFilePath.isNotEmpty() && File(tempFilePath).exists()
|
||||
if (hasTempFile && serverAcceptsRanges && bytesTotal + startByte > 1 shl 20) {
|
||||
// if failure can be resumed, post resume data
|
||||
processResumeData(
|
||||
ResumeData(
|
||||
task, tempFilePath, bytesTotal + startByte, eTagHeader
|
||||
), prefs
|
||||
)
|
||||
} else {
|
||||
// if it cannot be resumed, delete the temp file
|
||||
deleteTempFile()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the temp file associated with this download
|
||||
*/
|
||||
private fun deleteTempFile() {
|
||||
if (tempFilePath.isNotEmpty()) {
|
||||
try {
|
||||
val tempFile = File(tempFilePath)
|
||||
tempFile.delete()
|
||||
} catch (_: IOException) {
|
||||
Log.i(TAG, "Could not delete temp file at $tempFilePath")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the destination Uri at [uri]
|
||||
*/
|
||||
private fun deleteDestinationUri(uri: Uri) {
|
||||
try {
|
||||
context.appContext.contentResolver.delete(uri, null, null)
|
||||
} catch (_: Exception) {
|
||||
Log.i(TAG, "Could not delete file at $uri")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup by deleting the temp file or destination uri
|
||||
*/
|
||||
private fun cleanup(usesUri: Boolean, destUri: Uri?) {
|
||||
if (usesUri && destUri != null) {
|
||||
deleteDestinationUri(destUri)
|
||||
} else {
|
||||
deleteTempFile()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the group ownership of the downloaded file to unmark the file as a cache file.
|
||||
*
|
||||
* Determines the app's GID and then calls Os.chown. Logs success or failure.
|
||||
* Likely to fail if the file is in external storage.
|
||||
*/
|
||||
private fun setFileOwnership(destFile: File) {
|
||||
try {
|
||||
val finalPath = destFile.absolutePath
|
||||
val gid = context.appContext.applicationInfo.uid // App's own GID
|
||||
Os.chown(finalPath, -1, gid) // -1 for UID means keep current owner
|
||||
} catch (e: ErrnoException) {
|
||||
// Log the error, common if the filesystem doesn't support chown or due to permissions.
|
||||
// Check OsConstants for specific errno values if needed.
|
||||
Log.w(
|
||||
TAG,
|
||||
"Failed to change group ownership for ${destFile.absolutePath}: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.bbflight.background_downloader
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.WorkerParameters
|
||||
|
||||
class DownloadTaskWorker(context: Context, params: WorkerParameters) :
|
||||
TaskWorker(context, params) {
|
||||
|
||||
override fun createRunner(): TaskRunner {
|
||||
return DownloadTaskRunner(this)
|
||||
}
|
||||
}
|
||||
+344
@@ -0,0 +1,344 @@
|
||||
package com.bbflight.background_downloader
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.StatFs
|
||||
import android.util.Log
|
||||
import androidx.core.content.FileProvider.getUriForFile
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.bbflight.background_downloader.TaskRunner.Companion.TAG
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import java.io.File
|
||||
import java.net.URLDecoder
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
import kotlin.io.path.Path
|
||||
import kotlin.io.path.pathString
|
||||
|
||||
|
||||
/**
|
||||
* Installs a custom X509 TrustManager that accepts all certificates. Future
|
||||
* HTTPS calls will therefore not check the validity of the TLS certificate.
|
||||
*
|
||||
* DO NOT CONFIGURE THE DOWNLOADER TO BYPASS CERTIFICATE VALIDATION IN RELEASE
|
||||
*/
|
||||
fun acceptUntrustedCertificates() {
|
||||
try {
|
||||
// Create a trust manager that does not validate certificate chains
|
||||
val trustAllCerts = arrayOf<TrustManager>(
|
||||
@SuppressLint("CustomX509TrustManager")
|
||||
object : X509TrustManager {
|
||||
@SuppressLint("TrustAllX509TrustManager")
|
||||
override fun checkClientTrusted(
|
||||
chain: Array<java.security.cert.X509Certificate>,
|
||||
authType: String
|
||||
) {
|
||||
}
|
||||
|
||||
@SuppressLint("TrustAllX509TrustManager")
|
||||
override fun checkServerTrusted(
|
||||
chain: Array<java.security.cert.X509Certificate>,
|
||||
authType: String
|
||||
) {
|
||||
}
|
||||
|
||||
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> {
|
||||
return arrayOf()
|
||||
}
|
||||
})
|
||||
|
||||
// Install the all-trusting trust manager
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(null, trustAllCerts, java.security.SecureRandom())
|
||||
// Create an ssl socket factory with our all-trusting manager
|
||||
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
|
||||
Log.w(
|
||||
BDPlugin.TAG, "Bypassing TLS certificate validation\n" +
|
||||
"HTTPS calls will NOT check the validity of the TLS certificate."
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw RuntimeException(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if there is insufficient space to store a file of length
|
||||
* [contentLength]
|
||||
*
|
||||
* Returns false if [contentLength] <= 0
|
||||
* Returns false if configCheckAvailableSpace has not been set, or if available
|
||||
* space is greater than that setting
|
||||
* Returns true otherwise
|
||||
*/
|
||||
fun insufficientSpace(applicationContext: Context, contentLength: Long): Boolean {
|
||||
if (contentLength <= 0) {
|
||||
return false
|
||||
}
|
||||
val checkValue = PreferenceManager.getDefaultSharedPreferences(applicationContext)
|
||||
.getInt(BDPlugin.keyConfigCheckAvailableSpace, 0)
|
||||
if (checkValue <= 0) {
|
||||
return false
|
||||
}
|
||||
val path = Environment.getDataDirectory()
|
||||
val stat = StatFs(path.path)
|
||||
val available = stat.blockSizeLong * stat.availableBlocksLong
|
||||
return available - (BDPlugin.remainingBytesToDownload.values.sum()
|
||||
+ contentLength) < (checkValue.toLong() shl 20)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 0L, and a missing upper
|
||||
* range with null. If the string cannot be parsed, returns (0L, null)
|
||||
*/
|
||||
fun parseRange(rangeStr: String): Pair<Long, Long?> {
|
||||
val regex = Regex("""bytes=(\d*)-(\d*)""")
|
||||
val match = regex.find(rangeStr) ?: return Pair(0, null)
|
||||
val start = match.groupValues[1].toLongOrNull() ?: 0L
|
||||
val end = match.groupValues[2].toLongOrNull()
|
||||
return Pair(start, end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the content length extracted from the [responseHeaders], or from
|
||||
* the [task] headers
|
||||
*/
|
||||
fun getContentLength(responseHeaders: Map<String, List<String>>, task: Task): Long {
|
||||
// if response provides contentLength, return it
|
||||
val contentLength = responseHeaders["Content-Length"]?.get(0)?.toLongOrNull()
|
||||
?: responseHeaders["content-length"]?.get(0)?.toLongOrNull()
|
||||
?: -1L
|
||||
if (contentLength != -1L) {
|
||||
return contentLength
|
||||
}
|
||||
// try extracting it from Range header
|
||||
val taskRangeHeader = task.headers["Range"] ?: task.headers["range"] ?: ""
|
||||
val taskRange = parseRange(taskRangeHeader)
|
||||
if (taskRange.second != null) {
|
||||
val rangeLength = taskRange.second!! - taskRange.first + 1L
|
||||
Log.d(TAG, "TaskId ${task.taskId} contentLength set to $rangeLength based on Range header")
|
||||
return rangeLength
|
||||
}
|
||||
// try extracting it from a special "Known-Content-Length" header
|
||||
val knownLength = (task.headers["Known-Content-Length"]?.toLongOrNull()
|
||||
?: task.headers["known-content-length"]?.toLongOrNull()
|
||||
?: -1)
|
||||
if (knownLength != -1L) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"TaskId ${task.taskId} contentLength set to $knownLength based on Known-Content-Length header"
|
||||
)
|
||||
} else {
|
||||
Log.d(TAG, "TaskId ${task.taskId} contentLength undetermined")
|
||||
}
|
||||
return knownLength
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Return the path to the baseDir for this [baseDirectory], or null if path could not be reached
|
||||
*
|
||||
* Null only happens if external storage is requested but not available
|
||||
*/
|
||||
fun baseDirPath(context: Context, baseDirectory: BaseDirectory): String? {
|
||||
val useExternalStorage = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
.getInt(BDPlugin.keyConfigUseExternalStorage, -1) == 0
|
||||
val baseDirPath: String
|
||||
if (!useExternalStorage) {
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
baseDirPath = when (baseDirectory) {
|
||||
BaseDirectory.applicationDocuments -> Path(
|
||||
context.dataDir.path, "app_flutter"
|
||||
).pathString
|
||||
|
||||
BaseDirectory.temporary -> context.cacheDir.path
|
||||
BaseDirectory.applicationSupport -> context.filesDir.path
|
||||
BaseDirectory.applicationLibrary -> Path(
|
||||
context.filesDir.path, "Library"
|
||||
).pathString
|
||||
|
||||
BaseDirectory.root -> ""
|
||||
}
|
||||
} else {
|
||||
baseDirPath = when (baseDirectory) {
|
||||
BaseDirectory.applicationDocuments -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
"${context.dataDir.path}/app_flutter"
|
||||
} else {
|
||||
"${context.applicationInfo.dataDir}/app_flutter"
|
||||
}
|
||||
|
||||
BaseDirectory.temporary -> context.cacheDir.path
|
||||
BaseDirectory.applicationSupport -> context.filesDir.path
|
||||
BaseDirectory.applicationLibrary -> "${context.filesDir.path}/Library"
|
||||
BaseDirectory.root -> ""
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// external storage variant
|
||||
val externalStorageDirectory = context.getExternalFilesDir(null)
|
||||
val externalCacheDirectory = context.externalCacheDir
|
||||
if (externalStorageDirectory == null || externalCacheDirectory == null) {
|
||||
Log.e(TAG, "Could not access external storage")
|
||||
return null
|
||||
}
|
||||
baseDirPath = when (baseDirectory) {
|
||||
BaseDirectory.applicationDocuments -> externalStorageDirectory.path
|
||||
BaseDirectory.temporary -> externalCacheDirectory.path
|
||||
BaseDirectory.applicationSupport -> "${externalStorageDirectory.path}/Support"
|
||||
BaseDirectory.applicationLibrary -> "${externalStorageDirectory.path}/Library"
|
||||
BaseDirectory.root -> ""
|
||||
}
|
||||
}
|
||||
return baseDirPath
|
||||
}
|
||||
|
||||
fun getBasenameWithoutExtension(file: File): String {
|
||||
val fileName = file.name
|
||||
val extension = file.extension
|
||||
return fileName.substringBeforeLast(".$extension")
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Uri for the given [filePath], or null if not allowed/possible
|
||||
*/
|
||||
fun getUriFromFilePath(context: Context, filePath: String): String? {
|
||||
try {
|
||||
val contentUri = getUriForFile(
|
||||
context,
|
||||
context.packageName + ".com.bbflight.background_downloader.fileprovider",
|
||||
File(filePath)
|
||||
)
|
||||
return contentUri.toString()
|
||||
} catch (e: Exception) {
|
||||
Log.i(BDPlugin.TAG, "Failed to get Uri for file $filePath: $e")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggests a filename based on response headers and a URL. If none can be derived, returns ""
|
||||
*
|
||||
* @param responseHeaders The response headers map.
|
||||
* @param url The URL the file would be downloaded from.
|
||||
* @return A suggested filename, derived from the headers or the URL.
|
||||
*/
|
||||
fun suggestFilename(responseHeaders: Map<String, List<String>>, url: String): String {
|
||||
try {
|
||||
val disposition = (responseHeaders["Content-Disposition"]
|
||||
?: responseHeaders["content-disposition"])?.get(0)
|
||||
if (disposition != null) {
|
||||
// Try filename*=UTF-8'language'"encodedFilename"
|
||||
val encodedFilenameRegEx =
|
||||
Regex("""filename\*=\s*([^']+)'([^']*)'"?([^"]+)"?""", RegexOption.IGNORE_CASE)
|
||||
var match = encodedFilenameRegEx.find(disposition)
|
||||
if (match != null && match.groupValues[1].isNotEmpty() && match.groupValues[3].isNotEmpty()) {
|
||||
try {
|
||||
val suggestedFilename = if (match.groupValues[1].uppercase() == "UTF-8") {
|
||||
URLDecoder.decode(match.groupValues[3], "UTF-8")
|
||||
} else {
|
||||
match.groupValues[3]
|
||||
}
|
||||
return suggestedFilename
|
||||
} catch (_: IllegalArgumentException) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Could not interpret suggested filename (UTF-8 url encoded) ${match.groupValues[3]}"
|
||||
)
|
||||
}
|
||||
}
|
||||
// Try filename="filename"
|
||||
val plainFilenameRegEx =
|
||||
Regex("""filename=\s*"?([^"]+)"?.*$""", RegexOption.IGNORE_CASE)
|
||||
match = plainFilenameRegEx.find(disposition)
|
||||
if (match != null && match.groupValues[1].isNotEmpty()) {
|
||||
return match.groupValues[1]
|
||||
}
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
// Try filename derived from last path segment of the url
|
||||
try {
|
||||
val uri = Uri.parse(url)
|
||||
return getFilenameFromUri(uri)
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
return "" // Default fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the filename from the given [uri]
|
||||
*
|
||||
* Returns "" if not possible
|
||||
*
|
||||
* Slightly modified from lastPathSegment to account for possible subdirs in that last segment
|
||||
* (e.g. '/primary:Documents/dog-drawing.jpg' will have 'Documents/dog-drawing.jpg' as last path
|
||||
* segment, not 'dog-drawing.jpg')
|
||||
*/
|
||||
fun getFilenameFromUri(uri: Uri): String {
|
||||
val lastPathSegment = uri.lastPathSegment
|
||||
if (lastPathSegment != null) {
|
||||
val lastSlashIndex = lastPathSegment.lastIndexOf("/")
|
||||
val filename =
|
||||
if (lastSlashIndex != -1) lastPathSegment.substring(lastSlashIndex + 1) else lastPathSegment
|
||||
return filename
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Helper extension to filter out null values from Maps
|
||||
fun <K, V> Map<K, V>?.filterNotNull(): Map<K, V> {
|
||||
return this?.filter { (key, value) -> key != null && value != null } ?: emptyMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple Flutter result handler, completes the [completer] with true if the result of the
|
||||
* method call is true, and false otherwise
|
||||
*/
|
||||
class FlutterBooleanResultHandler(private val completer: CompletableDeferred<Boolean>) :
|
||||
MethodChannel.Result {
|
||||
|
||||
override fun success(result: Any?) {
|
||||
completer.complete(result == true)
|
||||
}
|
||||
|
||||
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
|
||||
Log.i(BDPlugin.TAG, "Flutter result error $errorCode: $errorMessage")
|
||||
completer.complete(false)
|
||||
}
|
||||
|
||||
override fun notImplemented() {
|
||||
Log.i(BDPlugin.TAG, "Flutter method not implemented")
|
||||
completer.complete(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flutter result handler, completes the [completer] with the result of the method call
|
||||
*/
|
||||
class FlutterResultHandler<T>(private val completer: CompletableDeferred<T?>) :
|
||||
MethodChannel.Result {
|
||||
|
||||
override fun success(result: Any?) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val castedResult = result as? T?
|
||||
completer.complete(castedResult)
|
||||
}
|
||||
|
||||
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
|
||||
Log.i(BDPlugin.TAG, "Flutter result error $errorCode: $errorMessage")
|
||||
completer.complete(null)
|
||||
}
|
||||
|
||||
override fun notImplemented() {
|
||||
Log.i(BDPlugin.TAG, "Flutter method not implemented")
|
||||
completer.complete(null)
|
||||
}
|
||||
}
|
||||
+398
@@ -0,0 +1,398 @@
|
||||
package com.bbflight.background_downloader
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.work.WorkManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.util.Date
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.PriorityBlockingQueue
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import android.app.job.JobScheduler
|
||||
import android.os.Build
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Queue that holds [EnqueueItem] items before they are actually enqueued as a WorkManager job
|
||||
*
|
||||
* Configure [maxConcurrent], [maxConcurrentByHost] and [maxConcurrentByGroup] to limit which items
|
||||
* can be enqueued simultaneously.
|
||||
*
|
||||
* Call:
|
||||
* [add] to add an [EnqueueItem]
|
||||
* [taskFinished] for all tasks that finish, so we may start a new one
|
||||
* [cancelAllTasks] to empty the queue (sends status updates)
|
||||
* [cancelTasksWithIds] to remove specific tasks (sends status updates)
|
||||
* [allTasks] for a list of [Task] matching a group
|
||||
* [taskForId] to get the task for a specific taskId
|
||||
*/
|
||||
class HoldingQueue(private val context: Context, private val workManager: WorkManager) {
|
||||
|
||||
var maxConcurrent: Int = 1 shl 20
|
||||
var maxConcurrentByHost: Int = 1 shl 20
|
||||
var maxConcurrentByGroup: Int = 1 shl 20
|
||||
val hostByTaskId = ConcurrentHashMap<String, String>()
|
||||
val enqueuedTaskIds: MutableSet<String> = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
private val concurrent = AtomicInteger(0)
|
||||
// so each slot is released only once. Guarded by [stateMutex]
|
||||
private val taskIdsHoldingSlot = HashSet<String>()
|
||||
private val concurrentByHost = ConcurrentHashMap<String, AtomicInteger>()
|
||||
private val concurrentByGroup = ConcurrentHashMap<String, AtomicInteger>()
|
||||
|
||||
private val queue = PriorityBlockingQueue<EnqueueItem>()
|
||||
private val taskFinishedQueue = Channel<Task>(capacity = Channel.UNLIMITED)
|
||||
|
||||
|
||||
private var job: Job? = null // timer job
|
||||
private val scope = CoroutineScope(Dispatchers.Default)
|
||||
private val processSignal = Channel<Unit>(Channel.UNLIMITED)
|
||||
val stateMutex = Mutex()
|
||||
|
||||
init {
|
||||
// coroutine to process the queue
|
||||
scope.launch {
|
||||
for (signal in processSignal) { // signal comes from [advanceQueue]
|
||||
stateMutex.withLock {
|
||||
val mustWait = ArrayList<EnqueueItem>()
|
||||
while (concurrent.get() < maxConcurrent && queue.isNotEmpty()) {
|
||||
val item = queue.poll() ?: break
|
||||
val host = item.task.host()
|
||||
val group = item.task.group
|
||||
// a task re-enqueued while its canceled run is still ending waits for
|
||||
// that run to release its slot, so two runs of a task never overlap
|
||||
if (!taskIdsHoldingSlot.contains(item.task.taskId) && (concurrentByHost[host]?.get()
|
||||
?: 0) < maxConcurrentByHost && (concurrentByGroup[group]?.get()
|
||||
?: 0) < maxConcurrentByGroup
|
||||
) {
|
||||
// enqueue this item after incrementing counters
|
||||
concurrent.incrementAndGet()
|
||||
concurrentByHost.computeIfAbsent(host) { AtomicInteger(0) }.incrementAndGet()
|
||||
concurrentByGroup.computeIfAbsent(group) { AtomicInteger(0) }.incrementAndGet()
|
||||
taskIdsHoldingSlot.add(item.task.taskId)
|
||||
item.enqueue(afterDelayMillis = 0)
|
||||
} else {
|
||||
// this item has to wait
|
||||
mustWait.add(item)
|
||||
}
|
||||
}
|
||||
// add mustWait items back to the queue
|
||||
queue.addAll(mustWait)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// coroutine to process 'taskFinished' messages
|
||||
scope.launch {
|
||||
for (task in taskFinishedQueue) {
|
||||
executeTaskFinished(task)
|
||||
}
|
||||
}
|
||||
|
||||
// initial state calculation
|
||||
scope.launch {
|
||||
calculateState()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add [EnqueueItem] [item] to the queue and advance the queue if possible
|
||||
*/
|
||||
suspend fun add(item: EnqueueItem) {
|
||||
stateMutex.withLock {
|
||||
queue.add(item)
|
||||
enqueuedTaskIds.add(item.task.taskId)
|
||||
NotificationService.registerEnqueue(
|
||||
item,
|
||||
success = true
|
||||
) // for group notification count
|
||||
}
|
||||
advanceQueue()
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals to the holdingQueue that a [task] has finished
|
||||
*
|
||||
* The processing is done async, so this method only adds
|
||||
* the task to the processing queue
|
||||
*/
|
||||
suspend fun taskFinished(task: Task) {
|
||||
taskFinishedQueue.send(task)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all [EnqueueItem] where their taskId is in [taskIds], sends a
|
||||
* [TaskStatus.canceled] update and returns a list of
|
||||
* taskIds that were cancelled this way
|
||||
*
|
||||
* Because this is used in combination with the WorkManager tasks, use of this method
|
||||
* requires the caller to acquire the [stateMutex]
|
||||
*/
|
||||
suspend fun cancelTasksWithIds(context: Context, taskIds: Iterable<String>): List<String> {
|
||||
val removedTaskIds: List<String>
|
||||
val toRemove = queue.filter { taskIds.contains(it.task.taskId) }
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
toRemove.forEach {
|
||||
queue.remove(it)
|
||||
TaskWorker.processStatusUpdate(it.task, TaskStatus.canceled, prefs, context = context)
|
||||
if (it.notificationConfigJsonString != null) {
|
||||
NotificationService.createUpdateNotificationWorker(
|
||||
context,
|
||||
bdJson.encodeToString(it.task),
|
||||
it.notificationConfigJsonString,
|
||||
TaskStatus.canceled.ordinal
|
||||
)
|
||||
}
|
||||
Log.i(BDPlugin.TAG, "Canceled task with id ${it.task.taskId}")
|
||||
}
|
||||
removedTaskIds = toRemove.map { it.task.taskId }.toMutableList()
|
||||
enqueuedTaskIds.removeAll(removedTaskIds.toSet())
|
||||
removedTaskIds.forEach { hostByTaskId.remove(it) }
|
||||
return removedTaskIds
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel (delete) all [EnqueueItem] matching [group], send a
|
||||
* [TaskStatus.canceled] for each and return the number of items cancelled
|
||||
*
|
||||
* Because this is used in combination with the WorkManager tasks, use of this method
|
||||
* requires the caller to acquire the [stateMutex]
|
||||
*/
|
||||
suspend fun cancelAllTasks(context: Context, group: String): Int {
|
||||
val taskIds =
|
||||
queue.filter { it.task.group == group }.map { it.task.taskId }.toMutableList()
|
||||
cancelTasksWithIds(context, taskIds)
|
||||
return taskIds.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Return task matching [taskId], or null
|
||||
*
|
||||
* Because this is used in combination with the WorkManager tasks, use of this method
|
||||
* requires the caller to acquire the [stateMutex]
|
||||
*/
|
||||
fun taskForId(taskId: String): Task? {
|
||||
val tasks = queue.filter { it.task.taskId == taskId }.map { it.task }
|
||||
if (tasks.isNotEmpty()) {
|
||||
return tasks.first()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Return list of [Task] for this [group]. If [group] is null, all tasks will be returned
|
||||
*
|
||||
* Because this is used in combination with the WorkManager tasks, use of this method
|
||||
* requires the caller to acquire the [stateMutex]
|
||||
*/
|
||||
fun allTasks(group: String?): List<Task> {
|
||||
return queue.filter { group == null || it.task.group == group }.map { it.task }
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the queue by signalling the queue processing coroutine
|
||||
*
|
||||
* Also restarts a timer that will advance the queue in 10 seconds, in case
|
||||
* it dries up
|
||||
*/
|
||||
private fun advanceQueue() {
|
||||
processSignal.trySend(Unit)
|
||||
if (queue.isNotEmpty()) {
|
||||
advanceQueueInFuture()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls [advanceQueue] in 10 seconds, thus ensuring it never dries up
|
||||
*
|
||||
* If called again before the timer fires, cancels the original timer and
|
||||
* resets to 10 seconds
|
||||
*/
|
||||
private fun advanceQueueInFuture() {
|
||||
job?.cancel()
|
||||
job = scope.launch {
|
||||
delay(10000)
|
||||
advanceQueue()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals to the holdingQueue that a [task] has finished
|
||||
*
|
||||
* Adjusts the state variables and advances the queue
|
||||
*/
|
||||
private suspend fun executeTaskFinished(task: Task) {
|
||||
hostByTaskId.remove(task.taskId)
|
||||
enqueuedTaskIds.remove(task.taskId)
|
||||
val host = task.host()
|
||||
val group = task.group
|
||||
stateMutex.withLock {
|
||||
if (taskIdsHoldingSlot.remove(task.taskId)) {
|
||||
concurrent.decrementAndGet()
|
||||
concurrentByHost[host]?.decrementAndGet()
|
||||
concurrentByGroup[group]?.decrementAndGet()
|
||||
}
|
||||
}
|
||||
advanceQueue()
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the [concurrent], [concurrentByHost] and [concurrentByGroup] values
|
||||
*
|
||||
* This is expensive, so is only done initially and when the [advanceQueueInFuture] timer
|
||||
* fires
|
||||
*/
|
||||
private suspend fun calculateState() {
|
||||
stateMutex.withLock {
|
||||
val workInfos = workManager.getWorkInfosByTag(BDPlugin.TAG).get()
|
||||
.filter { !it.state.isFinished }
|
||||
var totalCount = 0
|
||||
concurrentByHost.clear()
|
||||
concurrentByGroup.clear()
|
||||
taskIdsHoldingSlot.clear()
|
||||
for (workInfo in workInfos) {
|
||||
// update concurrentByHost
|
||||
try {
|
||||
val taskIdTag = workInfo.tags.first { it.startsWith("taskId=") }
|
||||
val taskId = taskIdTag.substring(startIndex = 7)
|
||||
if (!taskIdsHoldingSlot.add(taskId)) {
|
||||
// a task holds one slot, however many unfinished works it has
|
||||
continue
|
||||
}
|
||||
val host = hostByTaskId[taskId] ?: ""
|
||||
if (!concurrentByHost.containsKey(host)) {
|
||||
concurrentByHost[host] = AtomicInteger(0)
|
||||
}
|
||||
concurrentByHost[host]?.incrementAndGet()
|
||||
} catch (_: NoSuchElementException) {
|
||||
}
|
||||
totalCount++
|
||||
// update concurrentByGroup
|
||||
try {
|
||||
val groupTag = workInfo.tags.first { it.startsWith("group=") }
|
||||
val group = groupTag.substring(startIndex = 6)
|
||||
if (!concurrentByGroup.containsKey(group)) {
|
||||
concurrentByGroup[group] = AtomicInteger(0)
|
||||
}
|
||||
concurrentByGroup[group]?.incrementAndGet()
|
||||
} catch (_: NoSuchElementException) {
|
||||
}
|
||||
}
|
||||
// JobScheduler (UIDT)
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
val jobScheduler = context.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler
|
||||
val jobs = jobScheduler.allPendingJobs
|
||||
for (job in jobs) {
|
||||
if (job.service.className == UIDTJobService::class.java.name) {
|
||||
val taskJson = job.extras.getString(TaskWorker.keyTask)
|
||||
if (taskJson != null) {
|
||||
try {
|
||||
val task = bdJson.decodeFromString<Task>(taskJson)
|
||||
if (!taskIdsHoldingSlot.add(task.taskId)) {
|
||||
continue
|
||||
}
|
||||
totalCount++
|
||||
|
||||
val host = task.host()
|
||||
val group = task.group
|
||||
|
||||
if (!concurrentByHost.containsKey(host)) {
|
||||
concurrentByHost[host] = AtomicInteger(0)
|
||||
}
|
||||
concurrentByHost[host]?.incrementAndGet()
|
||||
|
||||
if (!concurrentByGroup.containsKey(group)) {
|
||||
concurrentByGroup[group] = AtomicInteger(0)
|
||||
}
|
||||
concurrentByGroup[group]?.incrementAndGet()
|
||||
} catch (e: Exception) {
|
||||
Log.w(BDPlugin.TAG, "Could not decode task from job info: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
concurrent.set(totalCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Holds data related to enqueueing a task
|
||||
*
|
||||
* Used in the context of changing the RequireWiFi setting (where tasks need to be re-enqueued)
|
||||
* and in the context of the [HoldingQueue]
|
||||
*/
|
||||
class EnqueueItem(
|
||||
val context: Context,
|
||||
val task: Task,
|
||||
val notificationConfigJsonString: String?,
|
||||
private val resumeData: ResumeData? = null,
|
||||
private val plugin: BDPlugin? = null,
|
||||
private val created: Date = Date()
|
||||
) : Comparable<EnqueueItem> {
|
||||
|
||||
/** Execute the re-enqueue after an appropriate delay */
|
||||
suspend fun enqueue(afterDelayMillis: Int = 1000) {
|
||||
delayUntil(afterDelayMillis)
|
||||
if (!BDPlugin.doEnqueue(
|
||||
context = context,
|
||||
task = task,
|
||||
notificationConfigJsonString = notificationConfigJsonString,
|
||||
resumeData = resumeData,
|
||||
plugin = plugin
|
||||
)
|
||||
) {
|
||||
Log.w(BDPlugin.TAG, "Delayed or retried enqueue failed for taskId ${task.taskId}")
|
||||
TaskWorker.processStatusUpdate(
|
||||
task, TaskStatus.failed, PreferenceManager.getDefaultSharedPreferences(context),
|
||||
taskException = TaskException(
|
||||
type = ExceptionType.general,
|
||||
description = "Delayed or retried enqueue failed"
|
||||
), context = context
|
||||
)
|
||||
BDPlugin.holdingQueue?.taskFinished(task)
|
||||
NotificationService.registerEnqueue(this, success = false)
|
||||
}
|
||||
delay(20)
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-enqueue after [afterDelayMillis] since creation, through the [HoldingQueue] if there is
|
||||
* one, so the task waits for a slot like any other
|
||||
*/
|
||||
suspend fun reEnqueue(afterDelayMillis: Int = 1000) {
|
||||
val holdingQueue = BDPlugin.holdingQueue
|
||||
if (holdingQueue == null) {
|
||||
enqueue(afterDelayMillis)
|
||||
} else {
|
||||
delayUntil(afterDelayMillis)
|
||||
Log.i(BDPlugin.TAG, "Re-enqueueing task with id ${task.taskId} to the HoldingQueue")
|
||||
holdingQueue.add(this)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun delayUntil(afterDelayMillis: Int) {
|
||||
val timeSinceCreatedMillis = Date().time - created.time
|
||||
if (timeSinceCreatedMillis < afterDelayMillis) {
|
||||
delay(afterDelayMillis - timeSinceCreatedMillis)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Items are sorted based on task priority first, then by task creation time
|
||||
*/
|
||||
override fun compareTo(other: EnqueueItem) = compareValuesBy(this, other,
|
||||
{ it.task.priority },
|
||||
{ it.task.creationTime })
|
||||
}
|
||||
+607
@@ -0,0 +1,607 @@
|
||||
@file:Suppress("EnumEntryName")
|
||||
|
||||
package com.bbflight.background_downloader
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import kotlinx.serialization.InternalSerializationApi
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.element
|
||||
import kotlinx.serialization.encoding.CompositeDecoder
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.encoding.decodeStructure
|
||||
import kotlinx.serialization.encoding.encodeStructure
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.net.MalformedURLException
|
||||
import java.net.URL
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.random.Random
|
||||
|
||||
val bdJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
|
||||
/// Base directory in which files will be stored, based on their relative
|
||||
/// path.
|
||||
///
|
||||
/// These correspond to the directories provided by the path_provider package
|
||||
@Serializable(with = BaseDirectorySerializer::class)
|
||||
enum class BaseDirectory {
|
||||
applicationDocuments, // getApplicationDocumentsDirectory()
|
||||
temporary, // getTemporaryDirectory()
|
||||
applicationSupport, // getApplicationSupportDirectory()
|
||||
applicationLibrary, // getApplicationSupportDirectory() subdir "Library"
|
||||
root // system root directory
|
||||
}
|
||||
|
||||
class BaseDirectorySerializer : EnumAsIntSerializer<BaseDirectory>(
|
||||
"BaseDirectory",
|
||||
{ it.ordinal },
|
||||
{ v -> BaseDirectory.entries.first { it.ordinal == v } }
|
||||
)
|
||||
|
||||
/// Type of updates requested for a group of tasks
|
||||
@Serializable(with = UpdatesSerializer::class)
|
||||
enum class Updates {
|
||||
none, // no status or progress updates
|
||||
status, // only calls upon change in DownloadTaskStatus
|
||||
progress, // only calls for progress
|
||||
statusAndProgress // calls also for progress along the way
|
||||
}
|
||||
|
||||
class UpdatesSerializer : EnumAsIntSerializer<Updates>(
|
||||
"Updates",
|
||||
{ it.ordinal },
|
||||
{ v -> Updates.entries.first { it.ordinal == v } }
|
||||
)
|
||||
|
||||
/**
|
||||
* Holds various options related to the task that are not included in the
|
||||
* task's properties, as they are rare
|
||||
*/
|
||||
@OptIn(InternalSerializationApi::class)
|
||||
@Serializable
|
||||
class TaskOptions(
|
||||
private val beforeTaskStartRawHandle: Long?,
|
||||
private val onTaskStartRawHandle: Long?,
|
||||
private val onTaskFinishedRawHandle: Long?,
|
||||
var auth: Auth?
|
||||
) {
|
||||
|
||||
fun hasBeforeStartCallback(): Boolean = beforeTaskStartRawHandle != null
|
||||
|
||||
fun hasOnStartCallback(): Boolean = onTaskStartRawHandle != null
|
||||
|
||||
fun hasOnFinishCallback(): Boolean = onTaskFinishedRawHandle != null
|
||||
}
|
||||
|
||||
/**
|
||||
* The Dart side Task
|
||||
*
|
||||
* A blend of UploadTask, DownloadTask and ParallelDownloadTask with [taskType] indicating what kind
|
||||
* of task this is
|
||||
*/
|
||||
@Suppress("SameParameterValue")
|
||||
@OptIn(InternalSerializationApi::class)
|
||||
@Serializable
|
||||
class Task(
|
||||
val taskId: String = "${Random.nextInt().absoluteValue}",
|
||||
val url: String,
|
||||
val urls: List<String> = listOf(),
|
||||
val filename: String,
|
||||
val headers: Map<String, String>,
|
||||
val httpRequestMethod: String = "GET",
|
||||
val chunks: Int = 1,
|
||||
val post: String? = null,
|
||||
val postIsBinary: Boolean = false,
|
||||
val fileField: String = "",
|
||||
val mimeType: String = "",
|
||||
val fields: Map<String, String> = mapOf(),
|
||||
val directory: String = "",
|
||||
val baseDirectory: BaseDirectory,
|
||||
val group: String,
|
||||
val updates: Updates,
|
||||
val requiresWiFi: Boolean = false,
|
||||
val retries: Int = 0,
|
||||
var retriesRemaining: Int = 0,
|
||||
val allowPause: Boolean = false,
|
||||
val priority: Int = 5,
|
||||
val metaData: String = "",
|
||||
val displayName: String = "",
|
||||
val creationTime: Long = System.currentTimeMillis(), // untouched, so kept as integer on Android side
|
||||
val options: TaskOptions? = null,
|
||||
val transferHints: List<Int>? = null,
|
||||
val stallTimeout: Long? = null,
|
||||
val taskType: String
|
||||
) {
|
||||
|
||||
/**
|
||||
* Returns a copy of the [Task] with optional changes to specific fields
|
||||
*/
|
||||
fun copyWith(
|
||||
taskId: String? = null,
|
||||
url: String? = null,
|
||||
urls: List<String>? = null,
|
||||
filename: String? = null,
|
||||
headers: Map<String, String>? = null,
|
||||
httpRequestMethod: String? = null,
|
||||
chunks: Int? = null,
|
||||
post: String? = null,
|
||||
fileField: String? = null,
|
||||
mimeType: String? = null,
|
||||
fields: Map<String, String>? = null,
|
||||
directory: String? = null,
|
||||
baseDirectory: BaseDirectory? = null,
|
||||
group: String? = null,
|
||||
updates: Updates? = null,
|
||||
requiresWiFi: Boolean? = null,
|
||||
retries: Int? = null,
|
||||
retriesRemaining: Int? = null,
|
||||
allowPause: Boolean? = null,
|
||||
priority: Int? = null,
|
||||
metaData: String? = null,
|
||||
displayName: String? = null,
|
||||
creationTime: Long? = null,
|
||||
options: TaskOptions? = null,
|
||||
transferHints: List<Int>? = null,
|
||||
stallTimeout: Long? = null,
|
||||
taskType: String? = null
|
||||
): Task {
|
||||
return Task(
|
||||
taskId = taskId ?: this.taskId,
|
||||
url = url ?: this.url,
|
||||
urls = urls ?: this.urls,
|
||||
filename = filename ?: this.filename,
|
||||
headers = headers ?: this.headers,
|
||||
httpRequestMethod = httpRequestMethod ?: this.httpRequestMethod,
|
||||
chunks = chunks ?: this.chunks,
|
||||
post = post ?: this.post,
|
||||
postIsBinary = this.postIsBinary,
|
||||
fileField = fileField ?: this.fileField,
|
||||
mimeType = mimeType ?: this.mimeType,
|
||||
fields = fields ?: this.fields,
|
||||
directory = directory ?: this.directory,
|
||||
baseDirectory = baseDirectory ?: this.baseDirectory,
|
||||
group = group ?: this.group,
|
||||
updates = updates ?: this.updates,
|
||||
requiresWiFi = requiresWiFi ?: this.requiresWiFi,
|
||||
retries = retries ?: this.retries,
|
||||
retriesRemaining = retriesRemaining ?: this.retriesRemaining,
|
||||
allowPause = allowPause ?: this.allowPause,
|
||||
priority = priority ?: this.priority,
|
||||
metaData = metaData ?: this.metaData,
|
||||
displayName = displayName ?: this.displayName,
|
||||
creationTime = creationTime ?: this.creationTime,
|
||||
options = options ?: this.options,
|
||||
transferHints = transferHints ?: this.transferHints,
|
||||
stallTimeout = stallTimeout ?: this.stallTimeout,
|
||||
taskType = taskType ?: this.taskType
|
||||
)
|
||||
}
|
||||
|
||||
/** True if this task expects to provide progress updates */
|
||||
fun providesProgressUpdates(): Boolean {
|
||||
return updates == Updates.progress ||
|
||||
updates == Updates.statusAndProgress
|
||||
}
|
||||
|
||||
/** True if this task expects to provide status updates */
|
||||
fun providesStatusUpdates(): Boolean {
|
||||
return updates == Updates.status ||
|
||||
updates == Updates.statusAndProgress
|
||||
}
|
||||
|
||||
/** True if this task is a DownloadTask, UriDownloadTask or ParallelDownloadTask */
|
||||
fun isDownloadTask(): Boolean {
|
||||
return taskType == "DownloadTask" || taskType == "UriDownloadTask" || taskType == "ParallelDownloadTask"
|
||||
}
|
||||
|
||||
/** True if this task is an UploadTask or MultiUploadTask */
|
||||
fun isUploadTask(): Boolean {
|
||||
return taskType == "UploadTask" || taskType == "UriUploadTask" || taskType == "MultiUploadTask"
|
||||
}
|
||||
|
||||
/** True if this task is a ParallelDownloadTask */
|
||||
fun isParallelDownloadTask(): Boolean {
|
||||
return taskType == "ParallelDownloadTask"
|
||||
}
|
||||
|
||||
/** True if this task is a MultiUploadTask */
|
||||
private fun isMultiUploadTask(): Boolean {
|
||||
return taskType == "MultiUploadTask"
|
||||
}
|
||||
|
||||
/** True if this task is a DataTask */
|
||||
fun isDataTask(): Boolean {
|
||||
return taskType == "DataTask"
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns full path (String) to the file,
|
||||
* based on [withFilename] or the [Task.filename] (default)
|
||||
*
|
||||
* If the task is a MultiUploadTask and no [withFilename] is given,
|
||||
* returns the empty string, as there is no single path that can be
|
||||
* returned.
|
||||
*/
|
||||
fun filePath(context: Context, withFilename: String? = null): String {
|
||||
if (isMultiUploadTask() && withFilename == null) {
|
||||
return ""
|
||||
}
|
||||
val filenameToUse = withFilename ?: filename
|
||||
val baseDirPath = baseDirPath(context, baseDirectory)
|
||||
?: throw IllegalStateException("External storage is requested but not available")
|
||||
return if (directory.isEmpty()) "$baseDirPath/${filenameToUse}" else
|
||||
"$baseDirPath/${directory}/${filenameToUse}"
|
||||
}
|
||||
|
||||
/**
|
||||
* 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"
|
||||
*/
|
||||
fun withSuggestedFilenameFromResponseHeaders(
|
||||
context: Context,
|
||||
responseHeaders: MutableMap<String, MutableList<String>>,
|
||||
unique: Boolean = false
|
||||
): Task {
|
||||
// Returns [Task] 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]
|
||||
fun taskWithUniqueFilename(task: Task, unique: Boolean): Task {
|
||||
if (!unique) {
|
||||
return task
|
||||
}
|
||||
val sequenceRegEx = Regex("""\((\d+)\)\.?[^.]*$""")
|
||||
val extensionRegEx = Regex("""\.[^.]*$""")
|
||||
var newTask = task
|
||||
var filePath = task.filePath(context)
|
||||
var exists = File(filePath).exists()
|
||||
while (exists) {
|
||||
val extension = extensionRegEx.find(newTask.filename)?.value ?: ""
|
||||
val match = sequenceRegEx.find(newTask.filename)
|
||||
val newSequence = (match?.groupValues?.get(1)?.toIntOrNull() ?: 0) + 1
|
||||
val newFilename = when (match) {
|
||||
null -> "${getBasenameWithoutExtension(File(newTask.filename))} ($newSequence)$extension"
|
||||
else -> "${
|
||||
newTask.filename.substring(
|
||||
0,
|
||||
match.range.first - 1
|
||||
)
|
||||
} ($newSequence)$extension"
|
||||
}
|
||||
newTask = newTask.copyWith(filename = newFilename)
|
||||
filePath = newTask.filePath(context)
|
||||
exists = File(filePath).exists()
|
||||
}
|
||||
return newTask
|
||||
}
|
||||
|
||||
// start of main method
|
||||
val suggestedFilename = suggestFilename(responseHeaders, url)
|
||||
return taskWithUniqueFilename(
|
||||
this.copyWith(filename = if (suggestedFilename.isNotEmpty()) suggestedFilename else this.filename),
|
||||
unique
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of fileData elements, one for each file to upload.
|
||||
* Each element is a triple containing fileField, full filePath or Uri, mimeType
|
||||
*
|
||||
* The lists are stored in the similarly named String fields as a JSON list,
|
||||
* with each list the same length. For the filenames list, if a filename refers
|
||||
* to a file that exists (i.e. it is a full path) then that is the filePath used,
|
||||
* otherwise the filename is appended to the [Task.baseDirectory] and [Task.directory]
|
||||
* to form a full file path. If instead the filePath is given as a Uri string, then
|
||||
* that string is used
|
||||
*/
|
||||
fun extractFilesData(context: Context): List<Triple<String, String, String>> {
|
||||
val fileFields = bdJson.decodeFromString<List<String>>(fileField)
|
||||
val filenames = bdJson.decodeFromString<List<String>>(filename)
|
||||
val mimeTypes = bdJson.decodeFromString<List<String>>(mimeType)
|
||||
val result = ArrayList<Triple<String, String, String>>()
|
||||
for (i in fileFields.indices) {
|
||||
val filenameOrPathOrUriString = filenames[i]
|
||||
val maybeUri = Uri.parse(filenameOrPathOrUriString)
|
||||
if (maybeUri.scheme == "content" || maybeUri.scheme == "file") {
|
||||
// for Uri, keep the Uri string
|
||||
result.add(Triple(fileFields[i], filenameOrPathOrUriString, mimeTypes[i]))
|
||||
} else {
|
||||
// For filePath, check if full path or construct it
|
||||
if (File(filenameOrPathOrUriString).exists()) {
|
||||
result.add(Triple(fileFields[i], filenameOrPathOrUriString, mimeTypes[i]))
|
||||
} else {
|
||||
result.add(
|
||||
Triple(
|
||||
fileFields[i],
|
||||
filePath(context, withFilename = filenameOrPathOrUriString),
|
||||
mimeTypes[i]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the hos of the url in this task
|
||||
*/
|
||||
fun host() = try {
|
||||
URL(url).host
|
||||
} catch (e: MalformedURLException) {
|
||||
""
|
||||
}
|
||||
|
||||
/** True if this task has a filename
|
||||
* False if it has only a URI, or if the filename is '?'
|
||||
*/
|
||||
fun hasFilename(): Boolean {
|
||||
val filename = UriUtils.unpack(filename).first
|
||||
return filename != null && filename != "?"
|
||||
}
|
||||
|
||||
|
||||
override fun toString(): String {
|
||||
return "Task(taskId='$taskId', url='$url', filename='$filename', headers=$headers, httpRequestMethod=$httpRequestMethod, post=$post, fileField='$fileField', mimeType='$mimeType', fields=$fields, directory='$directory', baseDirectory=$baseDirectory, group='$group', updates=$updates, requiresWiFi=$requiresWiFi, retries=$retries, retriesRemaining=$retriesRemaining, allowPause=$allowPause, priority=$priority, metaData='$metaData', displayName='$displayName', creationTime=$creationTime, options=$options, transferHints=$transferHints, stallTimeout=$stallTimeout, taskType='$taskType')"
|
||||
}
|
||||
|
||||
/**
|
||||
* An equality test on a [Task] is a test on the [taskId] only
|
||||
*/
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as Task
|
||||
|
||||
return taskId == other.taskId
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return taskId.hashCode()
|
||||
}
|
||||
}
|
||||
|
||||
/** Defines a set of possible states which a [Task] can be in.
|
||||
*
|
||||
* Must match the Dart equivalent enum, as value are passed as ordinal/index integer
|
||||
*/
|
||||
@Serializable(with = TaskStatusSerializer::class)
|
||||
enum class TaskStatus {
|
||||
enqueued,
|
||||
running,
|
||||
complete,
|
||||
notFound,
|
||||
failed,
|
||||
canceled,
|
||||
waitingToRetry,
|
||||
paused;
|
||||
|
||||
private fun isNotFinalState(): Boolean {
|
||||
return this == enqueued || this == running || this == waitingToRetry || this == paused
|
||||
}
|
||||
|
||||
fun isFinalState(): Boolean {
|
||||
return !isNotFinalState()
|
||||
}
|
||||
}
|
||||
|
||||
class TaskStatusSerializer : EnumAsIntSerializer<TaskStatus>(
|
||||
"TaskStatus",
|
||||
{ it.ordinal },
|
||||
{ v -> TaskStatus.entries.first { it.ordinal == v } }
|
||||
)
|
||||
|
||||
/** Holds data associated with a task status update
|
||||
*
|
||||
* Stored locally in JSON format if posting on background channel fails,
|
||||
* otherwise getter [argList] is used to extract the list of arguments
|
||||
* to be passed to the background channel as arguments to the "statusUpdate" method invocation
|
||||
*/
|
||||
@Serializable
|
||||
@OptIn(InternalSerializationApi::class)
|
||||
data class TaskStatusUpdate(
|
||||
val task: Task,
|
||||
val taskStatus: TaskStatus, // note Dart field name is 'status'
|
||||
val exception: TaskException?,
|
||||
val responseBody: String?,
|
||||
val responseStatusCode: Int?,
|
||||
val responseHeaders: Map<String, String>?,
|
||||
val mimeType: String?,
|
||||
val charSet: String?
|
||||
) {
|
||||
|
||||
/**
|
||||
* Returns the list of arguments that represents this [TaskStatusUpdate] when posting
|
||||
* a "statusUpdate" on the backgroundChannel
|
||||
*
|
||||
* Included data differs between failed tasks and other status updates
|
||||
*/
|
||||
val argList
|
||||
get() = if (taskStatus == TaskStatus.failed) mutableListOf(
|
||||
taskStatus.ordinal,
|
||||
exception?.type?.typeString,
|
||||
exception?.description,
|
||||
exception?.httpResponseCode,
|
||||
responseBody
|
||||
) else mutableListOf(
|
||||
taskStatus.ordinal,
|
||||
if (taskStatus.isFinalState()) responseBody else null,
|
||||
if (taskStatus.isFinalState()) responseHeaders else null,
|
||||
if (taskStatus == TaskStatus.complete || taskStatus == TaskStatus.notFound) responseStatusCode else null,
|
||||
if (taskStatus.isFinalState()) mimeType else null,
|
||||
if (taskStatus.isFinalState()) charSet else null
|
||||
)
|
||||
|
||||
override fun toString(): String {
|
||||
return "TaskStatusUpdate(task=$task, taskStatus=$taskStatus, exception=$exception, responseBody=$responseBody, responseStatusCode=$responseStatusCode, responseHeaders=$responseHeaders, mimeType=$mimeType, charSet=$charSet)"
|
||||
}
|
||||
}
|
||||
|
||||
/** Holds data associated with a task progress update, for local storage */
|
||||
@Serializable
|
||||
@OptIn(InternalSerializationApi::class)
|
||||
data class TaskProgressUpdate(val task: Task, val progress: Double, val expectedFileSize: Long)
|
||||
|
||||
/// Holds data associated with a resume
|
||||
@Serializable
|
||||
@OptIn(InternalSerializationApi::class)
|
||||
data class ResumeData(
|
||||
val task: Task,
|
||||
val data: String,
|
||||
val requiredStartByte: Long,
|
||||
val eTag: String?
|
||||
)
|
||||
|
||||
/**
|
||||
* The type of a [TaskException]
|
||||
*
|
||||
* Exceptions are handled differently on the Kotlin side because they are only a vehicle to message
|
||||
* to the Flutter side. An exception class hierarchy is therefore not required in Kotlin, and the
|
||||
* single [TaskException] class has a field for the [TaskException.type] of exception, as well as all possible
|
||||
* exception fields.
|
||||
* The [TaskException.type] (as a String using the enum's [ExceptionType.typeString]) is used on the
|
||||
* Flutter side to create the appropriate Exception subclass.
|
||||
*/
|
||||
enum class ExceptionType(val typeString: String) {
|
||||
/// General error
|
||||
general("TaskException"),
|
||||
|
||||
/// Could not save or find file, or create directory
|
||||
fileSystem("TaskFileSystemException"),
|
||||
|
||||
/// URL incorrect
|
||||
url("TaskUrlException"),
|
||||
|
||||
/// Connection problem, eg host not found, timeout
|
||||
connection("TaskConnectionException"),
|
||||
|
||||
/// Could not resume or pause task
|
||||
resume("TaskResumeException"),
|
||||
|
||||
/// Invalid HTTP response
|
||||
httpResponse("TaskHttpException")
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains error information associated with a failed [Task]
|
||||
*
|
||||
* The [type] categorizes the exception, used to create the appropriate subclass on the Flutter side
|
||||
* The [httpResponseCode] is only valid if >0 and may offer details about the
|
||||
* nature of the error
|
||||
* The [description] is typically taken from the platform-generated
|
||||
* error message, or from the plugin. The localization is undefined
|
||||
*/
|
||||
@Serializable(with = TaskExceptionSerializer::class)
|
||||
class TaskException(
|
||||
val type: ExceptionType,
|
||||
val httpResponseCode: Int = -1,
|
||||
val description: String = ""
|
||||
)
|
||||
|
||||
object TaskExceptionSerializer : KSerializer<TaskException> {
|
||||
override val descriptor = buildClassSerialDescriptor("TaskException") {
|
||||
element<String>("type")
|
||||
element<Int>("httpResponseCode")
|
||||
element<String>("description")
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, value: TaskException) {
|
||||
encoder.encodeStructure(descriptor) {
|
||||
encodeStringElement(
|
||||
descriptor, 0, when (value.type) {
|
||||
ExceptionType.fileSystem -> "TaskFileSystemException"
|
||||
ExceptionType.url -> "TaskUrlException"
|
||||
ExceptionType.connection -> "TaskConnectionException"
|
||||
ExceptionType.resume -> "TaskResumeException"
|
||||
ExceptionType.httpResponse -> "TaskHttpException"
|
||||
else -> "TaskException"
|
||||
}
|
||||
)
|
||||
encodeIntElement(descriptor, 1, value.httpResponseCode)
|
||||
encodeStringElement(descriptor, 2, value.description)
|
||||
}
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): TaskException {
|
||||
return decoder.decodeStructure(descriptor) {
|
||||
var type: ExceptionType? = null
|
||||
var httpResponseCode = -1
|
||||
var description = ""
|
||||
|
||||
while (true) {
|
||||
when (val index = decodeElementIndex(descriptor)) {
|
||||
0 -> type = when (decodeStringElement(descriptor, 0)) {
|
||||
"TaskFileSystemException" -> ExceptionType.fileSystem
|
||||
"TaskUrlException" -> ExceptionType.url
|
||||
"TaskConnectionException" -> ExceptionType.connection
|
||||
"TaskResumeException" -> ExceptionType.resume
|
||||
"TaskHttpException" -> ExceptionType.httpResponse
|
||||
else -> ExceptionType.general
|
||||
}
|
||||
|
||||
1 -> httpResponseCode = decodeIntElement(descriptor, 1)
|
||||
2 -> description = decodeStringElement(descriptor, 2)
|
||||
CompositeDecoder.DECODE_DONE -> break
|
||||
else -> error("Unexpected index: $index")
|
||||
}
|
||||
}
|
||||
|
||||
TaskException(type!!, httpResponseCode, description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Wifi requirement modes at the application level
|
||||
*/
|
||||
enum class RequireWiFi {
|
||||
asSetByTask,
|
||||
forAllTasks,
|
||||
forNoTasks
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializer for enums, such that they are encoded as an Int representing
|
||||
* the ordinal (index) of the value, instead of the String representation of
|
||||
* the value.
|
||||
*/
|
||||
open class EnumAsIntSerializer<T : Enum<*>>(
|
||||
serialName: String,
|
||||
val serialize: (v: T) -> Int,
|
||||
val deserialize: (v: Int) -> T
|
||||
) : KSerializer<T> {
|
||||
override val descriptor: SerialDescriptor =
|
||||
PrimitiveSerialDescriptor(serialName, PrimitiveKind.INT)
|
||||
|
||||
override fun serialize(encoder: Encoder, value: T) {
|
||||
encoder.encodeInt(serialize(value))
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): T {
|
||||
val v = decoder.decodeInt()
|
||||
return deserialize(v)
|
||||
}
|
||||
}
|
||||
+1155
File diff suppressed because it is too large.
Load diff
+39
@@ -0,0 +1,39 @@
|
||||
package com.bbflight.background_downloader
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.content.FileProvider.getUriForFile
|
||||
import java.io.File
|
||||
|
||||
|
||||
class OpenFileProvider : FileProvider(R.xml.bgd_file_paths)
|
||||
|
||||
/**
|
||||
* Opens the file at the given path or URI in the default file manager app.
|
||||
*/
|
||||
fun doOpenFile(activity: Activity, filePathOrUriString: String, mimeType: String): Boolean {
|
||||
val uri = Uri.parse(filePathOrUriString)
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
try {
|
||||
val contentUri =
|
||||
if (uri.scheme == "content" || uri.scheme == "file") uri
|
||||
else
|
||||
getUriForFile(
|
||||
activity,
|
||||
activity.packageName + ".com.bbflight.background_downloader.fileprovider",
|
||||
File(filePathOrUriString)
|
||||
)
|
||||
intent.setDataAndType(contentUri, mimeType)
|
||||
intent.addFlags(
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
)
|
||||
activity.startActivity(intent)
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
Log.i(BDPlugin.TAG, "Failed to open file $filePathOrUriString: $e")
|
||||
}
|
||||
return false
|
||||
}
|
||||
+484
@@ -0,0 +1,484 @@
|
||||
package com.bbflight.background_downloader
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.InternalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.encodeToString
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.min
|
||||
import kotlin.random.Random
|
||||
|
||||
class ParallelDownloadTaskRunner(context: TaskJobContext) : TaskRunner(context) {
|
||||
|
||||
private var parallelDownloadContentLength = -1L
|
||||
private var chunks: List<Chunk> = mutableListOf()
|
||||
private var chunksJsonString = ""
|
||||
private var parallelTaskStatusUpdateCompleter = CompletableDeferred<TaskStatus>()
|
||||
private var lastTaskStatus = TaskStatus.enqueued
|
||||
|
||||
override fun determineIfResume(): Boolean {
|
||||
chunksJsonString = context.getInputString(keyResumeDataData) ?: ""
|
||||
return chunksJsonString.isNotEmpty()
|
||||
}
|
||||
|
||||
override suspend fun connectAndProcess(connection: HttpURLConnection): TaskStatus {
|
||||
// Register this runner with BDPlugin so it can receive chunk updates
|
||||
BDPlugin.parallelDownloadTaskWorkers[task.taskId] = this
|
||||
|
||||
canRunInForeground = true
|
||||
runInForeground = notificationConfig?.running != null
|
||||
connection.requestMethod = "HEAD"
|
||||
val result = super.connectAndProcess(connection)
|
||||
BDPlugin.parallelDownloadTaskWorkers.remove(task.taskId)
|
||||
return result
|
||||
}
|
||||
|
||||
override suspend fun process(
|
||||
connection: HttpURLConnection,
|
||||
): TaskStatus {
|
||||
return withContext(Dispatchers.Default) {
|
||||
var enqueueJob: Job? = null
|
||||
var testerJob: Job? = null
|
||||
try {
|
||||
enqueueJob = launch {
|
||||
if (!isResume) {
|
||||
// start the download by creating [Chunk]s and enqueuing chunk tasks
|
||||
if (connection.responseCode in listOf(200, 201, 202, 203, 204, 205, 206)) {
|
||||
// if no filename is set, get from headers or url, update task
|
||||
if (!task.hasFilename()) {
|
||||
task = task.withSuggestedFilenameFromResponseHeaders(
|
||||
context.appContext,
|
||||
connection.headerFields,
|
||||
unique = true
|
||||
)
|
||||
Log.d(
|
||||
TAG,
|
||||
"Suggested filename for taskId ${task.taskId}: ${task.filename}"
|
||||
)
|
||||
}
|
||||
extractResponseHeaders(connection.headerFields)
|
||||
responseStatusCode = connection.responseCode
|
||||
extractContentType(connection.headerFields)
|
||||
chunks = createChunks(task, connection.headerFields)
|
||||
for (chunk in chunks) {
|
||||
// Ask Dart side to enqueue the child task. Updates related to the child
|
||||
// will be sent to this (parent) task (the child's metaData is the parent taskId).
|
||||
postOnBackgroundChannel(
|
||||
"enqueueChild",
|
||||
task,
|
||||
bdJson.encodeToString<Task>(chunk.task),
|
||||
onFail =
|
||||
{
|
||||
// failed to enqueue child
|
||||
cancelAllChunkTasks()
|
||||
Log.i(
|
||||
TAG,
|
||||
"Failed to enqueue chunk task with id ${chunk.task.taskId}"
|
||||
)
|
||||
taskException = TaskException(
|
||||
ExceptionType.general,
|
||||
description = "Failed to enqueue chunk task with id ${chunk.task.taskId}"
|
||||
)
|
||||
parallelTaskStatusUpdateCompleter.complete(TaskStatus.failed)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// HTTP response code not OK
|
||||
Log.i(
|
||||
TAG,
|
||||
"Response code ${connection.responseCode} for taskId ${task.taskId}"
|
||||
)
|
||||
val errorContent = responseErrorContent(connection)
|
||||
taskException = TaskException(
|
||||
ExceptionType.httpResponse,
|
||||
httpResponseCode = connection.responseCode,
|
||||
description = if (errorContent?.isNotEmpty() == true) errorContent else connection.responseMessage
|
||||
)
|
||||
if (connection.responseCode == 404) {
|
||||
responseBody = errorContent
|
||||
parallelTaskStatusUpdateCompleter.complete(TaskStatus.notFound)
|
||||
} else {
|
||||
parallelTaskStatusUpdateCompleter.complete(TaskStatus.failed)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// resume: reconstruct [chunks] and wait for all chunk tasks to complete.
|
||||
// The Dart side will resume each chunk task, so we just wait for the
|
||||
// completer to complete
|
||||
chunks = bdJson.decodeFromString(chunksJsonString)
|
||||
parallelDownloadContentLength = chunks.fold(0L) { acc, chunk ->
|
||||
acc + chunk.toByte - chunk.fromByte + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
testerJob = launch {
|
||||
while (isActive) {
|
||||
// check if task is stopped (canceled), paused or timed out
|
||||
if (context.isTaskStopped) {
|
||||
withContext(NonCancellable) {
|
||||
cancelAllChunkTasks()
|
||||
parallelTaskStatusUpdateCompleter.complete(TaskStatus.failed)
|
||||
}
|
||||
break
|
||||
}
|
||||
// 'pause' is signalled by adding the taskId to a static list
|
||||
if (BDPlugin.pausedTaskIds.remove(task.taskId)) {
|
||||
pauseAllChunkTasks()
|
||||
postOnBackgroundChannel(
|
||||
"resumeData",
|
||||
task,
|
||||
bdJson.encodeToString<List<Chunk>>(chunks)
|
||||
)
|
||||
parallelTaskStatusUpdateCompleter.complete(TaskStatus.paused)
|
||||
break
|
||||
}
|
||||
delay(200)
|
||||
}
|
||||
}
|
||||
// wait for all chunks to finish
|
||||
return@withContext parallelTaskStatusUpdateCompleter.await()
|
||||
} catch (e: Exception) {
|
||||
|
||||
Log.i(TAG, "Exception for taskId ${task.taskId}: $e")
|
||||
taskException = TaskException(
|
||||
ExceptionType.general,
|
||||
description = "Exception for taskId ${task.taskId}: $e"
|
||||
)
|
||||
return@withContext TaskStatus.failed
|
||||
} finally {
|
||||
enqueueJob?.cancelAndJoin()
|
||||
testerJob?.cancelAndJoin()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process incoming [status] update for a chunk with [chunkTaskId]
|
||||
*
|
||||
* If status is failure, may include [taskException] and [responseBody]
|
||||
*/
|
||||
suspend fun chunkStatusUpdate(
|
||||
chunkTaskId: String,
|
||||
status: TaskStatus,
|
||||
taskException: TaskException?,
|
||||
responseBody: String?
|
||||
) {
|
||||
val chunk = chunks.firstOrNull { it.task.taskId == chunkTaskId }
|
||||
?: return // chunk is not part of this parent task
|
||||
val chunkTask = chunk.task
|
||||
// first check for fail -> retry
|
||||
if (status == TaskStatus.failed && chunkTask.retriesRemaining > 0) {
|
||||
chunkTask.retriesRemaining--
|
||||
val waitTimeSeconds = 2 shl (min(chunkTask.retries - chunkTask.retriesRemaining - 1, 8))
|
||||
Log.i(
|
||||
TAG,
|
||||
"Chunk task with taskId ${chunkTask.taskId} failed, waiting $waitTimeSeconds seconds before retrying. ${chunkTask.retriesRemaining} retries remaining"
|
||||
)
|
||||
delay(waitTimeSeconds * 1000L)
|
||||
postOnBackgroundChannel(
|
||||
"enqueueChild",
|
||||
task,
|
||||
bdJson.encodeToString<Task>(chunk.task),
|
||||
onFail = {
|
||||
chunkStatusUpdate(chunkTaskId, TaskStatus.failed, taskException, responseBody)
|
||||
})
|
||||
} else {
|
||||
// no retry
|
||||
val newStatusUpdate = updateChunkStatus(chunk, status)
|
||||
if (newStatusUpdate != null) {
|
||||
when (newStatusUpdate) {
|
||||
TaskStatus.complete -> {
|
||||
val stitchResult = stitchChunks()
|
||||
parallelTaskStatusUpdateCompleter.complete(stitchResult)
|
||||
}
|
||||
|
||||
TaskStatus.failed -> {
|
||||
this.taskException = taskException
|
||||
cancelAllChunkTasks()
|
||||
parallelTaskStatusUpdateCompleter.complete(TaskStatus.failed)
|
||||
}
|
||||
|
||||
TaskStatus.notFound -> {
|
||||
this.taskException = taskException
|
||||
this.responseBody = responseBody
|
||||
cancelAllChunkTasks()
|
||||
parallelTaskStatusUpdateCompleter.complete(TaskStatus.notFound)
|
||||
}
|
||||
|
||||
else -> {
|
||||
// ignore all other status updates
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Process incoming [progress] update for a chunk with [chunkTaskId].
|
||||
*
|
||||
* Recalculates overall task progress (based on the average of the chunk
|
||||
* task progress) and sends an update to the Dart side and updates the
|
||||
* notification at the appropriate interval
|
||||
*/
|
||||
suspend fun chunkProgressUpdate(chunkTaskId: String, progress: Double) {
|
||||
val chunk = chunks.firstOrNull { it.task.taskId == chunkTaskId }
|
||||
?: return // chunk is not part of this parent task
|
||||
if (progress > 0 && progress < 1) {
|
||||
val parentProgress = updateChunkProgress(chunk, progress)
|
||||
if (shouldSendProgressUpdate(parentProgress, System.currentTimeMillis())) {
|
||||
updateProgressAndNotify(parentProgress, parallelDownloadContentLength, task)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 NativeDownloader, which intercepts
|
||||
* status updates for the chunkGroup
|
||||
*/
|
||||
private fun updateChunkStatus(chunk: Chunk, status: TaskStatus): TaskStatus? {
|
||||
chunk.status = status
|
||||
val parentStatus = parentTaskStatus()
|
||||
if (parentStatus != null && parentStatus != lastTaskStatus) {
|
||||
lastTaskStatus = parentStatus
|
||||
return parentStatus
|
||||
}
|
||||
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 NativeDownloader, which intercepts
|
||||
* status updates for the chunkGroup
|
||||
*/
|
||||
private fun parentTaskStatus(): TaskStatus? {
|
||||
val failed = chunks.firstOrNull { it.status == TaskStatus.failed }
|
||||
if (failed != null) {
|
||||
return TaskStatus.failed
|
||||
}
|
||||
|
||||
val notFound = chunks.firstOrNull { it.status == TaskStatus.notFound }
|
||||
if (notFound != null) {
|
||||
return TaskStatus.notFound
|
||||
}
|
||||
|
||||
val allComplete = chunks.all { it.status == TaskStatus.complete }
|
||||
if (allComplete) {
|
||||
return TaskStatus.complete
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Updates the chunk's progress and returns the average progress
|
||||
*
|
||||
* Returns the [progress] for the parent of this chunk, as derived from
|
||||
* its children by averaging
|
||||
*/
|
||||
private fun updateChunkProgress(chunk: Chunk, progress: Double): Double {
|
||||
chunk.progress = progress
|
||||
return chunks.fold(
|
||||
0.0
|
||||
) { previousValue, c ->
|
||||
previousValue + c.progress
|
||||
} / chunks.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the tasks associated with each chunk
|
||||
*
|
||||
* Accomplished by sending list of taskIds to cancel to the NativeDownloader
|
||||
*/
|
||||
suspend fun cancelAllChunkTasks() {
|
||||
postOnBackgroundChannel(
|
||||
"cancelTasksWithId",
|
||||
task,
|
||||
bdJson.encodeToString<List<String>>(chunks.map { it.task.taskId })
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the tasks associated with each chunk
|
||||
*
|
||||
* Accomplished by sending a json decoded list of tasks to cancel
|
||||
* to the NativeDownloader
|
||||
*/
|
||||
private suspend fun pauseAllChunkTasks() {
|
||||
postOnBackgroundChannel("pauseTasks", task, bdJson.encodeToString<List<Task>>(chunks.map { it.task }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Stitch all chunks together into one file
|
||||
*/
|
||||
private suspend fun stitchChunks(): TaskStatus {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val dataBuffer = ByteArray(bufferSize)
|
||||
var numBytes: Int
|
||||
val outFile = File(task.filePath(context.appContext))
|
||||
if (outFile.exists()) {
|
||||
outFile.delete()
|
||||
}
|
||||
FileOutputStream(outFile).use { outStream ->
|
||||
for (chunk in chunks.sortedBy { it.fromByte }) {
|
||||
val inFile = File(chunk.task.filePath(context.appContext))
|
||||
if (!inFile.exists()) {
|
||||
throw java.io.FileNotFoundException("Missing chunk file: ${inFile.path}")
|
||||
}
|
||||
FileInputStream(inFile).use { inStream ->
|
||||
while (inStream.read(
|
||||
dataBuffer, 0,
|
||||
bufferSize
|
||||
)
|
||||
.also { numBytes = it } != -1
|
||||
) {
|
||||
outStream.write(dataBuffer, 0, numBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
outStream.flush()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.i(TAG, "Error stitching chunks: $e\n${e.stackTraceToString()}")
|
||||
taskException = TaskException(
|
||||
ExceptionType.fileSystem,
|
||||
description = "Error stitching chunks: $e"
|
||||
)
|
||||
return@withContext TaskStatus.failed
|
||||
} finally {
|
||||
for (chunk in chunks) {
|
||||
try {
|
||||
val file = File(chunk.task.filePath(context.appContext))
|
||||
file.delete()
|
||||
} catch (_: Exception) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
return@withContext TaskStatus.complete
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns a list of chunk information for this task, and sets
|
||||
* [parallelDownloadContentLength] to the total length of the download
|
||||
*
|
||||
* Throws a IllegalStateException if any information is missing, which should lead
|
||||
* to a failure of the ParallelDownloadTask
|
||||
*/
|
||||
private fun createChunks(
|
||||
task: Task,
|
||||
headers: MutableMap<String, MutableList<String>>
|
||||
): List<Chunk> {
|
||||
val numChunks = task.urls.size * task.chunks
|
||||
try {
|
||||
val contentLength = getContentLength(headers, task)
|
||||
if (contentLength <= 0) {
|
||||
throw IllegalStateException("Server does not provide content length - cannot chunk download. If you know the length, set Range or Known-Content-Length header")
|
||||
}
|
||||
parallelDownloadContentLength = contentLength
|
||||
context.updateEstimatedNetworkBytes(contentLength, 0L)
|
||||
try {
|
||||
headers.entries
|
||||
.first { (it.key == "accept-ranges" || it.key == "Accept-Ranges") && it.value.first() == "bytes" }
|
||||
} catch (_: NoSuchElementException) {
|
||||
throw IllegalStateException("Server does not accept ranges - cannot chunk download")
|
||||
}
|
||||
val chunkSize = (contentLength / numChunks) + 1
|
||||
return (0 until numChunks).map { i ->
|
||||
Chunk(
|
||||
parentTask = task,
|
||||
url = task.urls[i % task.urls.size],
|
||||
filename = "com.bbflight.background_downloader.${Random.nextInt().absoluteValue}",
|
||||
from = i * chunkSize,
|
||||
to = min(i * chunkSize + chunkSize - 1, contentLength - 1)
|
||||
)
|
||||
}
|
||||
} catch (_: NoSuchElementException) {
|
||||
throw IllegalStateException("Server does not provide content length - cannot chunk download. If you know the length, set Range or Known-Content-Length header")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@OptIn(InternalSerializationApi::class)
|
||||
@Serializable
|
||||
class Chunk private constructor(
|
||||
private val parentTaskId: String,
|
||||
private val url: String,
|
||||
private val filename: String,
|
||||
val task: Task,
|
||||
val fromByte: Long,
|
||||
val toByte: Long,
|
||||
var status: TaskStatus = TaskStatus.enqueued,
|
||||
var progress: Double = 0.0
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* Returns [Updates] that is based on the [parentTask]
|
||||
*/
|
||||
fun updatesBasedOnParent(parentTask: Task): Updates {
|
||||
return when (parentTask.updates) {
|
||||
Updates.none, Updates.status -> Updates.status
|
||||
Updates.progress, Updates.statusAndProgress -> Updates.statusAndProgress
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor that also creates the [Task] associated with this [Chunk]
|
||||
*/
|
||||
constructor(parentTask: Task, url: String, filename: String, from: Long, to: Long) : this(
|
||||
parentTaskId = parentTask.taskId,
|
||||
url,
|
||||
filename,
|
||||
task = Task(
|
||||
url = url,
|
||||
filename = filename,
|
||||
headers = parentTask.headers + mapOf("Range" to "bytes=$from-$to"),
|
||||
httpRequestMethod = "GET",
|
||||
chunks = 1,
|
||||
post = null,
|
||||
fileField = "",
|
||||
mimeType = "",
|
||||
baseDirectory = BaseDirectory.applicationDocuments,
|
||||
group = TaskRunner.chunkGroup,
|
||||
updates = updatesBasedOnParent(parentTask),
|
||||
retries = parentTask.retries,
|
||||
retriesRemaining = parentTask.retries,
|
||||
requiresWiFi = parentTask.requiresWiFi,
|
||||
allowPause = parentTask.allowPause,
|
||||
priority = parentTask.priority,
|
||||
metaData = bdJson.encodeToString<ChunkTaskMetaData>(ChunkTaskMetaData(parentTask.taskId, from, to)),
|
||||
taskType = "DownloadTask"
|
||||
),
|
||||
from,
|
||||
to
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@OptIn(InternalSerializationApi::class)
|
||||
/// Holder for metaData for [Task] related to a [Chunk]
|
||||
data class ChunkTaskMetaData(val parentTaskId: String, val from: Long, val to: Long)
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.bbflight.background_downloader
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.WorkerParameters
|
||||
|
||||
class ParallelDownloadTaskWorker(context: Context, params: WorkerParameters) :
|
||||
TaskWorker(context, params) {
|
||||
|
||||
override fun createRunner(): TaskRunner {
|
||||
return ParallelDownloadTaskRunner(this)
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
@file:Suppress("EnumEntryName")
|
||||
|
||||
package com.bbflight.background_downloader
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.ActivityCompat
|
||||
|
||||
enum class PermissionType {
|
||||
notifications, androidSharedStorage, iosAddToPhotoLibrary, iosChangePhotoLibrary
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
enum class PermissionStatus {
|
||||
undetermined, denied, granted, partial, requestError
|
||||
}
|
||||
|
||||
class PermissionsService {
|
||||
companion object {
|
||||
private const val baseRequestCode = 373900
|
||||
|
||||
/**
|
||||
* Requests [PermissionStatus] for permission of [permissionType]
|
||||
*
|
||||
* Requests [PermissionStatus.granted] for unknown permissions
|
||||
*/
|
||||
fun getPermissionStatus(
|
||||
context: Context,
|
||||
permissionType: PermissionType
|
||||
): PermissionStatus {
|
||||
when (permissionType) {
|
||||
PermissionType.notifications -> {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
// On Android 33+, check/ask for permission
|
||||
val auth = ActivityCompat.checkSelfPermission(
|
||||
context, Manifest.permission.POST_NOTIFICATIONS
|
||||
)
|
||||
if (auth == PackageManager.PERMISSION_GRANTED)
|
||||
PermissionStatus.granted
|
||||
else
|
||||
PermissionStatus.denied
|
||||
} else {
|
||||
PermissionStatus.granted
|
||||
}
|
||||
}
|
||||
|
||||
PermissionType.androidSharedStorage -> {
|
||||
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q
|
||||
) {
|
||||
// On Android before 29, check/ask for permission
|
||||
val auth = ActivityCompat.checkSelfPermission(
|
||||
context, Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
)
|
||||
if (auth == PackageManager.PERMISSION_GRANTED)
|
||||
PermissionStatus.granted
|
||||
else
|
||||
PermissionStatus.denied
|
||||
} else {
|
||||
PermissionStatus.granted
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
return PermissionStatus.granted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests permission for [permissionType] adn returns a Boolean
|
||||
* If true a request has been made that the flutter side should wait for
|
||||
* If false, no request has been made -> check permission status separately
|
||||
*
|
||||
* Does not wait for result. Once result is received via the
|
||||
* [onRequestPermissionsResult], a message is sent back to the
|
||||
* Flutter side with the permission result
|
||||
*/
|
||||
fun requestPermission(plugin: BDPlugin, permissionType: PermissionType): Boolean {
|
||||
val requestCode = baseRequestCode + permissionType.ordinal
|
||||
when (permissionType) {
|
||||
PermissionType.notifications -> {
|
||||
// On Android 33+, check/ask for permission
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (plugin.activity == null) {
|
||||
return false
|
||||
}
|
||||
ActivityCompat.requestPermissions(
|
||||
plugin.activity!!, arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
requestCode
|
||||
)
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
PermissionType.androidSharedStorage -> {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
// On Android before 29, check/ask for permission
|
||||
if (plugin.activity == null) {
|
||||
return false
|
||||
}
|
||||
ActivityCompat.requestPermissions(
|
||||
plugin.activity!!,
|
||||
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
|
||||
requestCode
|
||||
)
|
||||
// completer will be completed via [onRequestPermissionsResult]
|
||||
return true
|
||||
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if Android shouldShowRequestPermissionRationale returns true for this
|
||||
* [permissionType]. Returns false for unrecognized [permissionType]
|
||||
*/
|
||||
fun shouldShowRequestPermissionRationale(
|
||||
plugin: BDPlugin,
|
||||
permissionType: PermissionType
|
||||
): Boolean {
|
||||
val activity = plugin.activity
|
||||
return if (activity != null) {
|
||||
when (permissionType) {
|
||||
PermissionType.notifications -> {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
ActivityCompat.shouldShowRequestPermissionRationale(
|
||||
activity,
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
PermissionType.androidSharedStorage -> {
|
||||
return ActivityCompat.shouldShowRequestPermissionRationale(
|
||||
activity,
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
)
|
||||
}
|
||||
|
||||
else -> return false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Processes the onRequestPermissionsResult received by the Activity
|
||||
fun onRequestPermissionsResult(
|
||||
plugin: BDPlugin,
|
||||
requestCode: Int, grantResults: IntArray
|
||||
): Boolean {
|
||||
val granted =
|
||||
(grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED)
|
||||
return when (requestCode) {
|
||||
baseRequestCode + PermissionType.notifications.ordinal,
|
||||
baseRequestCode + PermissionType.androidSharedStorage.ordinal -> {
|
||||
sendPermissionResult(plugin, granted)
|
||||
true
|
||||
}
|
||||
|
||||
else -> {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the [granted] result via the background channel to Flutter
|
||||
*
|
||||
* Only one request can be 'in flight' at any time
|
||||
*/
|
||||
private fun sendPermissionResult(
|
||||
plugin: BDPlugin,
|
||||
granted: Boolean
|
||||
) {
|
||||
// send with dummy task ("")
|
||||
val bgChannel = BDPlugin.backgroundChannel(plugin)
|
||||
bgChannel?.invokeMethod(
|
||||
"permissionRequestResult",
|
||||
listOf(
|
||||
"",
|
||||
if (granted) PermissionStatus.granted.ordinal else PermissionStatus.denied.ordinal
|
||||
)
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package com.bbflight.background_downloader
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Queue service that executes things on a queue, to ensure ordered execution
|
||||
* and potentially manage delay
|
||||
*/
|
||||
@Suppress("ConstPropertyName")
|
||||
object QueueService {
|
||||
private val scope = CoroutineScope(Dispatchers.Default)
|
||||
|
||||
private val taskIdDeletionQueue = Channel<String>(capacity = Channel.UNLIMITED)
|
||||
private var lastTaskIdAdditionTime: Long = 0
|
||||
private const val minTaskIdDeletionDelay: Long = 2000L //ms
|
||||
|
||||
private val backgroundPostQueue = Channel<BackgroundPost>(capacity = Channel.UNLIMITED)
|
||||
|
||||
|
||||
/**
|
||||
* Starts listening to the queues and processes each item
|
||||
*
|
||||
* taskIdDeletionQueue:
|
||||
* Each item is a taskId and it will be removed from the BDPlugin.pluginByTaskId,
|
||||
* BDPlugin.bgChannelByTaskId, BDPlugin.localResumeData
|
||||
* and BDPlugin.notificationConfigs maps
|
||||
*
|
||||
* backgroundPostQueue:
|
||||
* Each item is a [BackgroundPost] that will be posted on the UI thread, and its
|
||||
* success completer will complete with true if successfully posted
|
||||
*/
|
||||
init {
|
||||
scope.launch {
|
||||
for (taskId in taskIdDeletionQueue) {
|
||||
val now = System.currentTimeMillis()
|
||||
val elapsed = now - lastTaskIdAdditionTime
|
||||
if (elapsed < minTaskIdDeletionDelay) {
|
||||
delay(minTaskIdDeletionDelay - elapsed)
|
||||
}
|
||||
BDPlugin.flutterEngineByTaskId.remove(taskId)
|
||||
BDPlugin.bgChannelByTaskId.remove(taskId)
|
||||
BDPlugin.localResumeData.remove(taskId)
|
||||
BDPlugin.notificationConfigJsonStrings.remove(taskId)
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
for (bgPost in backgroundPostQueue) {
|
||||
val success = CompletableDeferred<Boolean>()
|
||||
launch(Dispatchers.Main) {
|
||||
try {
|
||||
val argList = mutableListOf<Any>(
|
||||
TaskWorker.taskToJsonString(bgPost.task)
|
||||
)
|
||||
if (bgPost.arg is ArrayList<*>) {
|
||||
argList.addAll(bgPost.arg)
|
||||
} else {
|
||||
argList.add(bgPost.arg)
|
||||
}
|
||||
val bgChannel = BDPlugin.backgroundChannel(taskId = bgPost.task.taskId)
|
||||
if (bgChannel != null) {
|
||||
bgChannel.invokeMethod(
|
||||
bgPost.method, argList, FlutterBooleanResultHandler(success)
|
||||
)
|
||||
} else {
|
||||
Log.i(
|
||||
TaskRunner.TAG,
|
||||
"Could not post ${bgPost.method} to background channel"
|
||||
)
|
||||
success.complete(false)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
TaskRunner.TAG,
|
||||
"Exception trying to post ${bgPost.method} to background channel: ${e.message}"
|
||||
)
|
||||
if (!success.isCompleted) {
|
||||
success.complete(false)
|
||||
}
|
||||
}
|
||||
val posted = !BDPlugin.forceFailPostOnBackgroundChannel && success.await()
|
||||
if (posted) {
|
||||
bgPost.onSuccess?.invoke()
|
||||
} else {
|
||||
bgPost.onFail?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Remove this [taskId] from the [BDPlugin.bgChannelByTaskId] map and the
|
||||
* [BDPlugin.localResumeData] map
|
||||
*/
|
||||
suspend fun cleanupTaskId(taskId: String) {
|
||||
lastTaskIdAdditionTime = System.currentTimeMillis()
|
||||
taskIdDeletionQueue.send(taskId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Post this [BackgroundPost] on the background channel
|
||||
*/
|
||||
suspend fun postOnBackgroundChannel(bgPost: BackgroundPost) {
|
||||
backgroundPostQueue.send(bgPost)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* BackgroundPost to be sent via backgroundChannel to Flutter, used in [QueueService]
|
||||
*/
|
||||
data class BackgroundPost(
|
||||
val task: Task,
|
||||
val method: String,
|
||||
val arg: Any,
|
||||
val onFail: (suspend () -> Unit)? = null,
|
||||
val onSuccess: (suspend () -> Unit)? = null
|
||||
)
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
package com.bbflight.background_downloader
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.annotation.RequiresApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
/// Scoped Storage destinations for Android
|
||||
@Suppress("EnumEntryName")
|
||||
enum class SharedStorage { downloads, images, video, audio, files, external }
|
||||
|
||||
val leadingPathSeparatorRegEx = Regex("""^/+""")
|
||||
val trailingPathSeparatorRegEx = Regex("""/$""")
|
||||
|
||||
/**
|
||||
* Moves the file from filePath to the shared storage destination and returns the path to
|
||||
* that file if successful, or null if not. If [asUriString] is true, the URI will be returned
|
||||
* instead of the file path
|
||||
*
|
||||
* If successful, the original file will have been deleted
|
||||
*/
|
||||
suspend fun moveToSharedStorage(
|
||||
context: Context,
|
||||
filePathOrUriString: String,
|
||||
destination: SharedStorage,
|
||||
directory: String,
|
||||
mimeType: String?,
|
||||
asUriString: Boolean
|
||||
): String? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
return moveToPublicDirectory(filePathOrUriString, destination, directory)
|
||||
}
|
||||
val file = if (filePathOrUriString.startsWith("file://")) {
|
||||
File(Uri.parse(filePathOrUriString).path!!)
|
||||
} else File(filePathOrUriString)
|
||||
if (!file.exists()) {
|
||||
Log.i(
|
||||
BDPlugin.TAG,
|
||||
"File $filePathOrUriString does not exist -> cannot move to shared storage"
|
||||
)
|
||||
return null
|
||||
}
|
||||
val cleanDirectory =
|
||||
trailingPathSeparatorRegEx.replace(leadingPathSeparatorRegEx.replace(directory, ""), "")
|
||||
// Set up the content values for the new file
|
||||
val contentValues = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, file.name)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, mimeType ?: getMimeType(file.name))
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, getRelativePath(destination, cleanDirectory))
|
||||
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||
}
|
||||
var success = false
|
||||
// Insert the new file into the MediaStore
|
||||
val resolver = context.contentResolver
|
||||
val uri = try {
|
||||
resolver.insert(getMediaStoreUri(destination), contentValues)
|
||||
} catch (e: Exception) {
|
||||
Log.i(BDPlugin.TAG, "Cannot insert $filePathOrUriString in MediaStore: $e")
|
||||
return null
|
||||
}
|
||||
if (uri != null) {
|
||||
try {
|
||||
// Get an OutputStream and write the contents of the file
|
||||
val os: OutputStream? = uri.let { resolver.openOutputStream(it) }
|
||||
if (os != null) {
|
||||
os.use { output ->
|
||||
FileInputStream(file).use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
success = true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.i(
|
||||
BDPlugin.TAG,
|
||||
"Error moving file $filePathOrUriString to shared storage: $e"
|
||||
)
|
||||
} finally {
|
||||
if (success) {
|
||||
contentValues.clear()
|
||||
contentValues.put(MediaStore.MediaColumns.IS_PENDING, 0)
|
||||
try {
|
||||
resolver.update(uri, contentValues, null, null)
|
||||
} catch (e: Exception) {
|
||||
Log.i(
|
||||
BDPlugin.TAG,
|
||||
"Failed to reset IS_PENDING: $e"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
resolver.delete(uri, null, null)
|
||||
} catch (e: Exception) {
|
||||
Log.i(
|
||||
BDPlugin.TAG,
|
||||
"Failed to delete incomplete MediaStore entry: $e"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// If the file was moved successfully, remove the original
|
||||
if (success) {
|
||||
file.delete()
|
||||
return if (asUriString) uri!!.toString() else pathFromUri(context, uri!!)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the file from filePath to the shared storage destination and returns the path to
|
||||
* that file if successful, or null if not
|
||||
*
|
||||
* If successful, the original file will have been deleted
|
||||
*
|
||||
* This implementation is for Android versions before Q and requires app permissions
|
||||
* READ_EXTERNAL_STORAGE and WRITE_EXTERNAL_STORAGE
|
||||
*/
|
||||
private fun moveToPublicDirectory(
|
||||
filePath: String, destination: SharedStorage, directory: String
|
||||
): String? {
|
||||
try {
|
||||
val file = File(filePath)
|
||||
if (!file.exists()) {
|
||||
Log.i(
|
||||
BDPlugin.TAG,
|
||||
"File $filePath does not exist -> cannot move to public directory"
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
val destinationMediaPath = getMediaStorePathBelowQ(destination)
|
||||
val rootDir = Environment.getExternalStoragePublicDirectory(destinationMediaPath)
|
||||
val destDir = File(rootDir, directory)
|
||||
if (!destDir.exists()) {
|
||||
destDir.mkdirs()
|
||||
}
|
||||
// try to get a new file name if already exist
|
||||
val maxCheck = 100
|
||||
var currentCheck = 1
|
||||
var destinationFile = File(destDir, file.name)
|
||||
val fileName = file.nameWithoutExtension
|
||||
val fileHasExtension = file.name.contains(".")
|
||||
val fileExtension = if (fileHasExtension) ".${file.extension}" else ""
|
||||
while (destinationFile.exists() && currentCheck < maxCheck) {
|
||||
destinationFile = File(destDir, "${fileName}_$currentCheck$fileExtension")
|
||||
currentCheck++
|
||||
}
|
||||
if (destinationFile.exists()) throw Exception("Destination file exist!")
|
||||
|
||||
// copy file
|
||||
destinationFile.outputStream().use { output ->
|
||||
FileInputStream(file).use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
file.delete()
|
||||
return destinationFile.absolutePath
|
||||
} catch (e: Exception) {
|
||||
Log.i(
|
||||
BDPlugin.TAG,
|
||||
"Unable to move file $filePath to public directory: $e"
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the file in shared storage, or null
|
||||
*
|
||||
* The [filePathOrUriString] can represent a file:// Uri but not a content:// Uri
|
||||
*
|
||||
* If [asUriString] is true, returns the URI as a String
|
||||
*/
|
||||
fun pathInSharedStorage(
|
||||
context: Context,
|
||||
filePathOrUriString: String,
|
||||
destination: SharedStorage,
|
||||
directory: String,
|
||||
asUriString: Boolean
|
||||
): String? {
|
||||
// Check if filePath is a file:// URI
|
||||
val fileUri = Uri.parse(filePathOrUriString)
|
||||
val fileName = if (fileUri.scheme == "file") {
|
||||
File(fileUri.path!!).name
|
||||
} else {
|
||||
File(filePathOrUriString).name
|
||||
}
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
val destinationMediaPath = getMediaStorePathBelowQ(destination)
|
||||
val rootDir = Environment.getExternalStoragePublicDirectory(destinationMediaPath)
|
||||
val destDir = File(rootDir, directory)
|
||||
val destinationFile = File(destDir, fileName)
|
||||
return destinationFile.path
|
||||
}
|
||||
// Version above Q uses MediaStore
|
||||
context.contentResolver.query(
|
||||
getMediaStoreUri(destination),
|
||||
arrayOf(
|
||||
MediaStore.Images.Media.DATA,
|
||||
MediaStore.MediaColumns._ID
|
||||
), // same for all collections AFAIK
|
||||
"${MediaStore.MediaColumns.DISPLAY_NAME} = ?",
|
||||
arrayOf(fileName),
|
||||
null
|
||||
)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
return if (asUriString) {
|
||||
Uri.withAppendedPath(
|
||||
getMediaStoreUri(destination),
|
||||
cursor.getLong(1).toString()
|
||||
).toString()
|
||||
} else cursor.getString(0)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns path to media store [destination] for Android versions at or above Q
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
fun getMediaStoreUri(destination: SharedStorage): Uri {
|
||||
return when (destination) {
|
||||
SharedStorage.files -> MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
||||
SharedStorage.downloads -> MediaStore.Downloads.getContentUri(
|
||||
MediaStore.VOLUME_EXTERNAL_PRIMARY
|
||||
)
|
||||
|
||||
SharedStorage.images -> MediaStore.Images.Media.getContentUri(
|
||||
MediaStore.VOLUME_EXTERNAL_PRIMARY
|
||||
)
|
||||
|
||||
SharedStorage.video -> MediaStore.Video.Media.getContentUri(
|
||||
MediaStore.VOLUME_EXTERNAL_PRIMARY
|
||||
)
|
||||
|
||||
SharedStorage.audio -> MediaStore.Audio.Media.getContentUri(
|
||||
MediaStore.VOLUME_EXTERNAL_PRIMARY
|
||||
)
|
||||
|
||||
SharedStorage.external -> MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns path to media store [destination] for Android versions below Q
|
||||
*/
|
||||
private fun getMediaStorePathBelowQ(destination: SharedStorage): String {
|
||||
return when (destination) {
|
||||
SharedStorage.files -> Environment.DIRECTORY_DOCUMENTS
|
||||
SharedStorage.downloads -> Environment.DIRECTORY_DOWNLOADS
|
||||
SharedStorage.images -> Environment.DIRECTORY_PICTURES
|
||||
SharedStorage.video -> Environment.DIRECTORY_MOVIES
|
||||
SharedStorage.audio -> Environment.DIRECTORY_MUSIC
|
||||
SharedStorage.external -> ""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns file path to ScopedStorage [destination] and subdirectory [directory]
|
||||
*/
|
||||
private fun getRelativePath(destination: SharedStorage, directory: String): String {
|
||||
val sharedStorageDirectory = when (destination) {
|
||||
SharedStorage.files -> Environment.DIRECTORY_DOCUMENTS
|
||||
SharedStorage.downloads -> Environment.DIRECTORY_DOWNLOADS
|
||||
SharedStorage.images -> Environment.DIRECTORY_PICTURES
|
||||
SharedStorage.video -> Environment.DIRECTORY_MOVIES
|
||||
SharedStorage.audio -> Environment.DIRECTORY_MUSIC
|
||||
SharedStorage.external -> ""
|
||||
}
|
||||
return if (directory.isEmpty()) sharedStorageDirectory else "$sharedStorageDirectory/$directory"
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Return Mime type for this [fileNameOrUriString], as a String
|
||||
*
|
||||
* Defaults to application/octet-stream
|
||||
*/
|
||||
fun getMimeType(fileNameOrUriString: String): String {
|
||||
val extension = fileNameOrUriString.substringAfterLast(".", "")
|
||||
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
|
||||
?: "application/octet-stream"
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file path related to this MediaStore [uri], or null
|
||||
*/
|
||||
suspend fun pathFromUri(context: Context, uri: Uri): String? = withContext(Dispatchers.IO) {
|
||||
val projection = arrayOf(MediaStore.Images.Media.DATA)
|
||||
val cursor: Cursor? = context.contentResolver.query(uri, projection, null, null, null)
|
||||
cursor?.use {
|
||||
if (it.moveToFirst()) {
|
||||
val columnIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
|
||||
return@withContext it.getString(columnIndex)
|
||||
}
|
||||
}
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
|
||||
//suspend fun pathFromUri(context: Context, uri: Uri): String? {
|
||||
// val proj = arrayOf(MediaStore.Images.Media.DATA)
|
||||
// val loader = CursorLoader(context, uri, proj, null, null, null)
|
||||
// val cursor = withContext(Dispatchers.IO) { loader.loadInBackground() }
|
||||
// ?: return null
|
||||
// val columnIndex: Int = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
|
||||
// cursor.moveToFirst()
|
||||
// val result = cursor.getString(columnIndex)
|
||||
// cursor.close()
|
||||
// return result
|
||||
//}
|
||||
+1206
File diff suppressed because it is too large.
Load diff
+231
@@ -0,0 +1,231 @@
|
||||
package com.bbflight.background_downloader
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* The worker to execute one task
|
||||
*
|
||||
* Wraps [TaskRunner], delegating the actual work to it, and implements
|
||||
* [TaskJobContext] to provide the context for the [TaskRunner].
|
||||
*/
|
||||
@Suppress("ConstPropertyName")
|
||||
open class TaskWorker(
|
||||
val context: Context,
|
||||
workerParams: WorkerParameters
|
||||
) : CoroutineWorker(context, workerParams), TaskJobContext {
|
||||
|
||||
companion object {
|
||||
const val keyTask = "Task"
|
||||
const val keyNotificationConfig = "notificationConfig"
|
||||
const val keyResumeDataData = "tempFilename"
|
||||
const val keyStartByte = "startByte"
|
||||
const val keyETag = "eTag"
|
||||
const val bufferSize = 2 shl 12
|
||||
|
||||
/** Converts [Task] to JSON string representation */
|
||||
fun taskToJsonString(task: Task): String {
|
||||
return TaskRunner.taskToJsonString(task)
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a change in status for the task
|
||||
*
|
||||
* Delegates to [TaskRunner.processStatusUpdate]
|
||||
*/
|
||||
suspend fun processStatusUpdate(
|
||||
task: Task,
|
||||
status: TaskStatus,
|
||||
prefs: SharedPreferences,
|
||||
taskException: TaskException? = null,
|
||||
responseBody: String? = null,
|
||||
responseHeaders: Map<String, String>? = null,
|
||||
responseStatusCode: Int? = null,
|
||||
mimeType: String? = null,
|
||||
charSet: String? = null,
|
||||
context: Context
|
||||
) {
|
||||
TaskRunner.processStatusUpdate(
|
||||
task,
|
||||
status,
|
||||
prefs,
|
||||
taskException,
|
||||
responseBody,
|
||||
responseHeaders,
|
||||
responseStatusCode,
|
||||
mimeType,
|
||||
charSet,
|
||||
context
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override val appContext: Context
|
||||
get() = applicationContext
|
||||
override val runId: String
|
||||
get() = id.toString()
|
||||
|
||||
// TaskJobContext Mutable properties
|
||||
override lateinit var task: Task
|
||||
override var notificationConfig: NotificationConfig? = null
|
||||
override var notificationId: Int = 0
|
||||
override var notificationProgress: Double = 2.0 // indeterminate
|
||||
override var networkSpeed: Double = -1.0
|
||||
override var taskCanResume: Boolean = false
|
||||
override var notificationConfigJsonString: String? = null
|
||||
override var runInForeground: Boolean = false
|
||||
|
||||
override val isTaskStopped: Boolean
|
||||
get() = isStopped
|
||||
|
||||
|
||||
|
||||
override val isActive: Boolean
|
||||
get() = !isTaskStopped
|
||||
|
||||
override fun getInputLong(key: String, defaultValue: Long): Long {
|
||||
return inputData.getLong(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun getInputString(key: String): String? {
|
||||
return inputData.getString(key)
|
||||
}
|
||||
|
||||
override suspend fun setForegroundNotification(
|
||||
notificationId: Int,
|
||||
notification: Notification,
|
||||
notificationType: Int
|
||||
) {
|
||||
try {
|
||||
setForeground(ForegroundInfo(notificationId, notification, notificationType))
|
||||
} catch (e: IllegalStateException) {
|
||||
Log.d("TaskWorker", "Ignoring setForeground: worker already completed. ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateNotification(
|
||||
task: Task,
|
||||
status: TaskStatus,
|
||||
progress: Double,
|
||||
timeRemaining: Long
|
||||
) {
|
||||
NotificationService.updateNotification(this, status, progress, timeRemaining)
|
||||
}
|
||||
|
||||
override fun updateEstimatedNetworkBytes(downloadBytes: Long, uploadBytes: Long) {
|
||||
// No-op for TaskWorker
|
||||
}
|
||||
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
try {
|
||||
// Initialize task and notificationConfig from inputData
|
||||
val taskJson = inputData.getString(keyTask)
|
||||
if (taskJson != null) {
|
||||
task = bdJson.decodeFromString(taskJson)
|
||||
} else {
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
notificationConfigJsonString = inputData.getString(keyNotificationConfig)
|
||||
if (notificationConfigJsonString != null) {
|
||||
notificationConfig = bdJson.decodeFromString(notificationConfigJsonString!!)
|
||||
}
|
||||
|
||||
runInForeground = false
|
||||
|
||||
if (runAttemptCount > 0 && skipReRun()) {
|
||||
return Result.success()
|
||||
}
|
||||
val runner = createRunner()
|
||||
runner.run()
|
||||
|
||||
return Result.success()
|
||||
} catch (e: Exception) {
|
||||
return Result.failure()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this WorkManager re-run must end without running its task, because the
|
||||
* task already finished or another work supersedes it, and gives back a finished task's slot
|
||||
*
|
||||
* WorkManager starts a work again after it stopped it, and after a process death for works
|
||||
* it didn't see finish, without asking the holding queue
|
||||
*/
|
||||
private suspend fun skipReRun(): Boolean {
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext)
|
||||
val taskInTasksMap = getTaskMap(prefs).containsKey(task.taskId)
|
||||
val otherUnfinishedWorkIds = withContext(Dispatchers.IO) {
|
||||
WorkManager.getInstance(applicationContext)
|
||||
.getWorkInfosByTag("taskId=${task.taskId}").get()
|
||||
}.filter { !it.state.isFinished && it.id != id }.map { it.id.toString() }
|
||||
val attempt = "WorkManager re-run (attempt $runAttemptCount) of taskId ${task.taskId}"
|
||||
return when (reRunDecision(runAttemptCount, taskInTasksMap, otherUnfinishedWorkIds)) {
|
||||
ReRunDecision.run -> {
|
||||
Log.i(BDPlugin.TAG, "Running $attempt")
|
||||
false
|
||||
}
|
||||
|
||||
ReRunDecision.skipFinished -> {
|
||||
Log.i(BDPlugin.TAG, "Skipping $attempt: finished")
|
||||
// the slot the holding queue counted for this work, unless a cancel released it
|
||||
if (!BDPlugin.slotReleasedRunIds.remove(runId)) {
|
||||
BDPlugin.holdingQueue?.taskFinished(task)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
ReRunDecision.skipSuperseded -> {
|
||||
// the other work holds the task's slot, and releases it when it ends
|
||||
Log.i(BDPlugin.TAG, "Skipping $attempt: superseded by ${otherUnfinishedWorkIds.first()}")
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the appropriate [TaskRunner] for this worker
|
||||
*
|
||||
* Must be overridden by subclasses
|
||||
*/
|
||||
open fun createRunner(): TaskRunner {
|
||||
throw NotImplementedError("Subclasses must override createRunner")
|
||||
}
|
||||
}
|
||||
|
||||
/** What a WorkManager run of a task does, see [reRunDecision] */
|
||||
@Suppress("EnumEntryName")
|
||||
enum class ReRunDecision {
|
||||
run,
|
||||
skipFinished, // the task's final state was already processed
|
||||
skipSuperseded // another unfinished work for the task exists
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides whether a WorkManager run with [runAttemptCount] runs its task, given whether the task
|
||||
* is still in the tasks map ([taskInTasksMap]) and the ids of other unfinished works for it
|
||||
*
|
||||
* A first run (attempt 0) always runs. A re-run is skipped if another work supersedes it, or else
|
||||
* if its task already finished
|
||||
*/
|
||||
fun reRunDecision(
|
||||
runAttemptCount: Int,
|
||||
taskInTasksMap: Boolean,
|
||||
otherUnfinishedWorkIds: List<String>
|
||||
): ReRunDecision = when {
|
||||
runAttemptCount == 0 -> ReRunDecision.run
|
||||
otherUnfinishedWorkIds.isNotEmpty() -> ReRunDecision.skipSuperseded
|
||||
!taskInTasksMap -> ReRunDecision.skipFinished
|
||||
else -> ReRunDecision.run
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
package com.bbflight.background_downloader
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.job.JobParameters
|
||||
import android.app.job.JobService
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.PersistableBundle
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class UIDTJobService : JobService() {
|
||||
|
||||
val jobs = java.util.concurrent.ConcurrentHashMap<Int, Job>()
|
||||
val jobContexts = java.util.concurrent.ConcurrentHashMap<Int, UIDTJobContext>()
|
||||
|
||||
override fun onStartJob(params: JobParameters?): Boolean {
|
||||
Log.d(TaskRunner.TAG, "Starting UIDT JobService")
|
||||
if (params == null) return false
|
||||
|
||||
val extras = params.extras
|
||||
val taskJson = extras.getString(TaskWorker.keyTask)
|
||||
if (taskJson == null) {
|
||||
Log.e(TaskRunner.TAG, "Task JSON not found in job parameters")
|
||||
return false
|
||||
}
|
||||
|
||||
val jobContext = UIDTJobContext(this, params)
|
||||
try {
|
||||
jobContext.task = bdJson.decodeFromString(taskJson)
|
||||
jobContext.notificationConfigJsonString = extras.getString(TaskWorker.keyNotificationConfig)
|
||||
if (jobContext.notificationConfigJsonString != null) {
|
||||
jobContext.notificationConfig = bdJson.decodeFromString(jobContext.notificationConfigJsonString!!)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TaskRunner.TAG, "Failed to decode task or notification config: $e")
|
||||
return false
|
||||
}
|
||||
|
||||
// Determine runner based on task type.
|
||||
val runner = when (jobContext.task.taskType) {
|
||||
"DownloadTask" -> DownloadTaskRunner(jobContext)
|
||||
"UriDownloadTask" -> DownloadTaskRunner(jobContext)
|
||||
"UploadTask" -> UploadTaskRunner(jobContext)
|
||||
"UriUploadTask" -> UploadTaskRunner(jobContext)
|
||||
"MultiUploadTask" -> UploadTaskRunner(jobContext)
|
||||
"DataTask" -> DataTaskRunner(jobContext)
|
||||
"ParallelDownloadTask" -> ParallelDownloadTaskRunner(jobContext)
|
||||
else -> {
|
||||
Log.e(TaskRunner.TAG, "Unknown task type: ${jobContext.task.taskType}")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
jobContexts[params.jobId] = jobContext
|
||||
val job = CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
runner.run()
|
||||
Log.d(TaskRunner.TAG, "UIDT JobService finished for taskId ${jobContext.task.taskId}")
|
||||
} finally {
|
||||
jobs.remove(params.jobId)
|
||||
jobContexts.remove(params.jobId)
|
||||
jobFinished(params, false) // retries managed internally
|
||||
NotificationService.onUIDTJobFinished(jobContext)
|
||||
}
|
||||
}
|
||||
jobs[params.jobId] = job
|
||||
|
||||
return true // Work is still running on background thread
|
||||
}
|
||||
|
||||
override fun onStopJob(params: JobParameters?): Boolean {
|
||||
Log.i(TaskRunner.TAG, "Stopping UIDT JobService")
|
||||
if (params != null) {
|
||||
jobContexts[params.jobId]?.isStopped = true
|
||||
jobs.remove(params.jobId)?.cancel()
|
||||
jobContexts.remove(params.jobId)
|
||||
}
|
||||
return false // Do not reschedule automatically; background_downloader manages retries internally
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for a single job execution, holding the state for that specific task/job
|
||||
*/
|
||||
class UIDTJobContext(val service: JobService, val params: JobParameters) : TaskJobContext {
|
||||
// TaskJobContext Properties
|
||||
override lateinit var task: Task
|
||||
override var notificationConfig: NotificationConfig? = null
|
||||
override var notificationId: Int = 0
|
||||
override var notificationProgress: Double = 2.0
|
||||
override var networkSpeed: Double = -1.0
|
||||
override var taskCanResume: Boolean = false
|
||||
override var notificationConfigJsonString: String? = null
|
||||
override val runId: String? = null
|
||||
override var runInForeground: Boolean = true // UIDT always runs in foreground service
|
||||
|
||||
@Volatile
|
||||
var isStopped: Boolean = false
|
||||
|
||||
override val appContext: Context
|
||||
get() = service.applicationContext
|
||||
|
||||
override val isTaskStopped: Boolean
|
||||
get() = isStopped || !isActive
|
||||
|
||||
override val isActive: Boolean
|
||||
get() = !isStopped && ((service as? UIDTJobService)?.jobs?.get(params.jobId)?.isActive ?: true)
|
||||
|
||||
|
||||
override fun getInputLong(key: String, defaultValue: Long): Long {
|
||||
return params.extras.getLong(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun getInputString(key: String): String? {
|
||||
return params.extras.getString(key)
|
||||
}
|
||||
|
||||
override suspend fun setForegroundNotification(
|
||||
notificationId: Int,
|
||||
notification: Notification,
|
||||
notificationType: Int
|
||||
) {
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
service.setNotification(
|
||||
params,
|
||||
notificationId,
|
||||
notification,
|
||||
JobService.JOB_END_NOTIFICATION_POLICY_DETACH
|
||||
)
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
service.startForeground(notificationId, notification, notificationType)
|
||||
} else {
|
||||
service.startForeground(notificationId, notification)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateNotification(
|
||||
task: Task,
|
||||
status: TaskStatus,
|
||||
progress: Double,
|
||||
timeRemaining: Long
|
||||
) {
|
||||
NotificationService.updateNotification(this, status, progress, timeRemaining)
|
||||
}
|
||||
|
||||
override fun updateEstimatedNetworkBytes(downloadBytes: Long, uploadBytes: Long) {
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
service.updateEstimatedNetworkBytes(params, downloadBytes, uploadBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.bbflight.background_downloader
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.work.WorkerParameters
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Worker that updates the notification for a task, given a
|
||||
* NotificationConfig and a TaskStatus.
|
||||
*
|
||||
* Used to update a task notification initiated from the Dart side, when
|
||||
* no actual TaskWorker is active for the task, e.g. when failing for the
|
||||
* last time on retry. We need a [TaskWorker] in order to call the
|
||||
* [NotificationService] to update the associated notification.
|
||||
*
|
||||
* Note this implementation is only valid for two situations:
|
||||
* - taskStatus is null -> deletes the notification
|
||||
* - taskStatus is TaskStatus.failed -> displays error notification if needed
|
||||
*/
|
||||
class UpdateNotificationWorker(applicationContext: Context, workerParams: WorkerParameters) :
|
||||
TaskWorker(applicationContext, workerParams) {
|
||||
|
||||
companion object {
|
||||
const val keyTaskStatusOrdinal = "taskStatusOrdinal"
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
task = bdJson.decodeFromString(inputData.getString(keyTask)!!)
|
||||
notificationConfigJsonString = inputData.getString(keyNotificationConfig)
|
||||
notificationConfig =
|
||||
if (notificationConfigJsonString != null) bdJson.decodeFromString(notificationConfigJsonString!!) else null
|
||||
val taskStatusOrdinal = inputData.getInt(keyTaskStatusOrdinal, -1)
|
||||
notificationId = task.taskId.hashCode()
|
||||
if (taskStatusOrdinal == -1) {
|
||||
// delete notification
|
||||
with(NotificationManagerCompat.from(applicationContext)) {
|
||||
cancel(notificationId)
|
||||
}
|
||||
} else {
|
||||
// update notification for this taskStatus
|
||||
val taskStatus = TaskStatus.entries[taskStatusOrdinal]
|
||||
NotificationService.updateNotification(this, taskStatus)
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
+653
@@ -0,0 +1,653 @@
|
||||
package com.bbflight.background_downloader
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.Log
|
||||
import androidx.core.net.toFile
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.DataOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.InputStream
|
||||
import java.net.HttpURLConnection
|
||||
import android.database.Cursor
|
||||
|
||||
class UploadTaskRunner(context: TaskJobContext) : TaskRunner(context) {
|
||||
|
||||
companion object {
|
||||
private val asciiOnlyRegEx = Regex("^[\\x00-\\x7F]+$")
|
||||
private val jsonStringRegEx = Regex("^\\s*(\\{.*\\}|\\[.*\\])\\s*$")
|
||||
private val newlineRegEx = Regex("\r\n|\r|\n")
|
||||
const val boundary = "-----background_downloader-akjhfw281onqciyhnIk"
|
||||
const val lineFeed = "\r\n"
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the upload of the file
|
||||
*
|
||||
* If the [Task.post] field is set to "binary" then the file will be uploaded as a byte stream POST
|
||||
*
|
||||
* If the [Task.post] field is not "binary" then the file(s) will be uploaded as a multipart POST
|
||||
*
|
||||
* Note that the [Task.post] field is just used to set whether this is a binary or multipart
|
||||
* upload. The bytes that will be posted are derived from the file to be uploaded.
|
||||
*
|
||||
* Returns the [TaskStatus]
|
||||
*/
|
||||
override suspend fun process(
|
||||
connection: HttpURLConnection
|
||||
): TaskStatus {
|
||||
connection.doOutput = true
|
||||
val transferBytesResult =
|
||||
if (task.post?.lowercase() == "binary") {
|
||||
processBinaryUpload(connection)
|
||||
} else {
|
||||
processMultipartUpload(connection)
|
||||
}
|
||||
when (transferBytesResult) {
|
||||
TaskStatus.canceled -> {
|
||||
Log.i(TAG, "Canceled taskId ${task.taskId}")
|
||||
return TaskStatus.canceled
|
||||
}
|
||||
|
||||
TaskStatus.failed -> {
|
||||
return TaskStatus.failed
|
||||
}
|
||||
|
||||
TaskStatus.complete -> {
|
||||
extractResponseBody(connection)
|
||||
extractResponseHeaders(connection.headerFields)
|
||||
responseStatusCode = connection.responseCode
|
||||
if (connection.responseCode in 200..206) {
|
||||
Log.i(
|
||||
TAG, "Successfully uploaded taskId ${task.taskId}"
|
||||
)
|
||||
return TaskStatus.complete
|
||||
}
|
||||
Log.i(
|
||||
TAG,
|
||||
"Response code ${connection.responseCode} for taskId ${task.taskId}"
|
||||
)
|
||||
val errorContent = responseErrorContent(connection)
|
||||
taskException = TaskException(
|
||||
ExceptionType.httpResponse, httpResponseCode = connection.responseCode,
|
||||
description = if (errorContent?.isNotEmpty() == true) errorContent else connection.responseMessage
|
||||
)
|
||||
return if (connection.responseCode == 404) {
|
||||
TaskStatus.notFound
|
||||
} else {
|
||||
TaskStatus.failed
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
return TaskStatus.failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the binary upload of the file
|
||||
*
|
||||
* Content-Disposition header will be:
|
||||
* - set to 'attachment = "filename"' if the task.headers field does not contain an entry for
|
||||
* 'Content-Disposition'
|
||||
* - not set at all (i.e. omitted) if the task.headers field contains an entry for
|
||||
* 'Content-Disposition' with the value "" (an empty string)
|
||||
* - set to the value of task.headers['Content-Disposition'] in all other cases
|
||||
*
|
||||
* The mime-type will be set to [Task.mimeType]
|
||||
*
|
||||
* Returns the [TaskStatus]
|
||||
*/
|
||||
private suspend fun processBinaryUpload(
|
||||
connection: HttpURLConnection
|
||||
): TaskStatus {
|
||||
val (filename, fileUri) = UriUtils.unpack(task.filename)
|
||||
var resolvedMimeType = task.mimeType
|
||||
val (fileSize, inputStream) = withContext(Dispatchers.IO) { // Use Dispatchers.IO for file operations
|
||||
if (fileUri != null) {
|
||||
try {
|
||||
if (filename == null) {
|
||||
// attempt to set a filename for the uploaded file in the task object
|
||||
val derivedFilename = getFileNameFromUri(fileUri)
|
||||
if (derivedFilename != null) {
|
||||
task = task.copyWith(filename = UriUtils.pack(derivedFilename, fileUri))
|
||||
}
|
||||
if (resolvedMimeType.isEmpty()) resolvedMimeType =
|
||||
getMimeType(derivedFilename ?: "")
|
||||
}
|
||||
if (fileUri.scheme != "file") {
|
||||
// a content:// URI scheme is resolved via the contentResolver
|
||||
val contentResolver = context.appContext.contentResolver
|
||||
// Get file size from URI
|
||||
val fileSize =
|
||||
contentResolver.query(fileUri, null, null, null, null)?.use { cursor ->
|
||||
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
cursor.moveToFirst()
|
||||
if (sizeIndex != -1) cursor.getLong(sizeIndex) else null
|
||||
} ?: run {
|
||||
val message =
|
||||
"Could not open file or determine file size for URI: $fileUri"
|
||||
Log.w(TAG, message)
|
||||
taskException = TaskException(
|
||||
ExceptionType.fileSystem,
|
||||
description = message
|
||||
)
|
||||
return@withContext Pair(
|
||||
null,
|
||||
null
|
||||
) // Return nulls to indicate failure
|
||||
}
|
||||
if (resolvedMimeType.isEmpty()) resolvedMimeType =
|
||||
getMimeType(contentResolver, fileUri)
|
||||
// Get InputStream from URI
|
||||
val inputStream = contentResolver.openInputStream(fileUri) ?: run {
|
||||
val message = "Could not open input stream for URI: $fileUri"
|
||||
Log.w(TAG, message)
|
||||
taskException = TaskException(
|
||||
ExceptionType.fileSystem,
|
||||
description = message
|
||||
)
|
||||
return@withContext Pair(null, null) // Return nulls to indicate failure
|
||||
}
|
||||
Pair(fileSize, inputStream)
|
||||
} else {
|
||||
// a file:// Uri scheme is interpreted as a regular file path
|
||||
val file = fileUri.toFile()
|
||||
val fileSize = file.length()
|
||||
if (resolvedMimeType.isEmpty()) resolvedMimeType =
|
||||
getMimeType(file.name)
|
||||
Pair(fileSize, FileInputStream(file))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val message = "Error processing URI: ${task.directory}"
|
||||
Log.w(TAG, message, e)
|
||||
taskException = TaskException(
|
||||
ExceptionType.fileSystem,
|
||||
description = message
|
||||
)
|
||||
return@withContext Pair(null, null) // Return nulls to indicate failure
|
||||
}
|
||||
} else {
|
||||
val filePath = task.filePath(context.appContext)
|
||||
val file = File(filePath)
|
||||
if (!file.exists() || !file.isFile) {
|
||||
val message = "File to upload does not exist: $filePath"
|
||||
Log.w(TAG, message)
|
||||
taskException = TaskException(
|
||||
ExceptionType.fileSystem,
|
||||
description = message
|
||||
)
|
||||
return@withContext Pair(null, null) // Return nulls to indicate failure
|
||||
}
|
||||
val fileSize = file.length()
|
||||
if (fileSize <= 0) {
|
||||
val message = "File $filePath has 0 length"
|
||||
Log.w(TAG, message)
|
||||
taskException = TaskException(
|
||||
ExceptionType.fileSystem,
|
||||
description = message
|
||||
)
|
||||
return@withContext Pair(null, null)
|
||||
}
|
||||
if (resolvedMimeType.isEmpty()) resolvedMimeType =
|
||||
getMimeType(file.name)
|
||||
Pair(fileSize, FileInputStream(file))
|
||||
}
|
||||
}
|
||||
// Check for failures in getting fileSize and inputStream
|
||||
if (fileSize == null || inputStream == null) {
|
||||
try { inputStream?.close() } catch (_: Exception) {}
|
||||
return TaskStatus.failed
|
||||
}
|
||||
return inputStream.use { fis ->
|
||||
// Extract Range header information
|
||||
var start = 0L
|
||||
var end = fileSize - 1 // Default to the whole file
|
||||
val rangeHeader = task.headers["Range"]
|
||||
if (rangeHeader != null) {
|
||||
val match = Regex("""bytes=(\d+)-(\d*)""").find(rangeHeader)
|
||||
if (match != null) {
|
||||
start = match.groupValues[1].toLong()
|
||||
if (match.groupValues.size > 2 && match.groupValues[2].isNotEmpty()) {
|
||||
end = match.groupValues[2].toLong()
|
||||
}
|
||||
} else {
|
||||
val message = "Invalid Range header $rangeHeader"
|
||||
Log.w(TAG, message)
|
||||
taskException = TaskException(
|
||||
ExceptionType.general,
|
||||
description = message
|
||||
)
|
||||
return TaskStatus.failed
|
||||
}
|
||||
}
|
||||
val contentLength = end - start + 1
|
||||
determineRunInForeground(task, contentLength)
|
||||
context.updateEstimatedNetworkBytes(0L, contentLength)
|
||||
Log.d(TAG, "Binary upload for taskId ${task.taskId}")
|
||||
connection.setRequestProperty("Content-Type", resolvedMimeType)
|
||||
val taskContentDisposition =
|
||||
task.headers["Content-Disposition"] ?: task.headers["content-disposition"]
|
||||
if (taskContentDisposition != "") {
|
||||
connection.setRequestProperty(
|
||||
"Content-Disposition",
|
||||
taskContentDisposition
|
||||
?: ("attachment; filename=\"" + Uri.encode(task.filename) + "\"")
|
||||
)
|
||||
}
|
||||
connection.setRequestProperty("Content-Length", contentLength.toString())
|
||||
connection.setFixedLengthStreamingMode(contentLength)
|
||||
withContext(Dispatchers.IO) {
|
||||
if (rangeHeader != null) {
|
||||
// Special treatment for partial uploads
|
||||
fis.skip(start)
|
||||
}
|
||||
LimitedInputStream(fis, contentLength).use { limitedInputStream ->
|
||||
DataOutputStream(connection.outputStream.buffered()).use { outputStream ->
|
||||
return@withContext transferBytes(
|
||||
limitedInputStream,
|
||||
outputStream,
|
||||
if (rangeHeader != null) contentLength else fileSize,
|
||||
task
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Process the multi-part upload of one or more files, and potential form fields
|
||||
*
|
||||
* Form fields are taken from [Task.fields]. If only one file is to be uploaded,
|
||||
* then [Task.filename] determines the file (can be a file path or a Uri string),
|
||||
* and [Task.fileField] and [Task.mimeType] are used to set the file field name
|
||||
* and mime type respectively.
|
||||
* For MultiUploadTasks, the list of fileField, filePath and mimeType are
|
||||
* extracted from the [Task.fileField], [Task.filename] and [Task.mimeType] (which
|
||||
* for MultiUploadTasks contain a JSON encoded list of strings).
|
||||
*
|
||||
* The total content length is calculated from the sum of all parts if all files are
|
||||
* given as file paths or file:// Uris. If one or more files are given as content:// Uris,
|
||||
* then chunked encoding is used.
|
||||
*
|
||||
* The connection is set up, and the bytes for each part are transferred to the host.
|
||||
*
|
||||
* Returns the [TaskStatus]
|
||||
*/
|
||||
private suspend fun processMultipartUpload(
|
||||
connection: HttpURLConnection,
|
||||
): TaskStatus {
|
||||
// field portion of the multipart, all in one string
|
||||
// multiple values should be encoded as '"value1", "value2", ...'
|
||||
val multiValueRegEx = Regex("""^(?:"[^"]+"\s*,\s*)+"[^"]+"$""")
|
||||
var fieldsString = ""
|
||||
for (entry in task.fields.entries) {
|
||||
// Check if the entry value matches the multiple values format
|
||||
if (multiValueRegEx.matches(entry.value)) {
|
||||
// Extract multiple values from entry.value
|
||||
val valueMatches = Regex(""""([^"]+)"""").findAll(entry.value)
|
||||
for (match in valueMatches) {
|
||||
fieldsString += fieldEntry(entry.key, match.groupValues[1])
|
||||
}
|
||||
} else {
|
||||
// Handle single value for key
|
||||
fieldsString += fieldEntry(entry.key, entry.value)
|
||||
}
|
||||
}
|
||||
// File portion of the multi-part
|
||||
// Assumes list of files. If only one file, that becomes a list of length one.
|
||||
// For each file, determine contentDispositionString, contentTypeString
|
||||
// and file length or InputStream, so that we can calculate total size of
|
||||
// upload or use chunked encoding.
|
||||
val separator = "$lineFeed--$boundary$lineFeed" // between files
|
||||
val terminator = "$lineFeed--$boundary--$lineFeed" // after last file
|
||||
val fileUri = UriUtils.uriFromStringValue(task.filename)
|
||||
val filePath = task.filePath(context.appContext)
|
||||
// fileData's second field contains either a file path or a Uri string
|
||||
val filesData = if (filePath.isNotEmpty()) {
|
||||
listOf(
|
||||
Triple(task.fileField, fileUri?.toString() ?: filePath, task.mimeType)
|
||||
)
|
||||
} else {
|
||||
task.extractFilesData(context.appContext)
|
||||
}
|
||||
val contentDispositionStrings = ArrayList<String>()
|
||||
val contentTypeStrings = ArrayList<String>()
|
||||
val fileLengthsOrStreams = ArrayList<Pair<Long?, InputStream?>>()
|
||||
try {
|
||||
var useChunkedEncoding = false
|
||||
for ((fileField, pathOrUriString, mimeType) in filesData) {
|
||||
var resolvedMimeType = mimeType // we need to change it if it is empty
|
||||
try {
|
||||
val fileUri = UriUtils.uriFromStringValue(pathOrUriString)
|
||||
val (fileSize, inputStream) = if (fileUri != null) {
|
||||
if (fileUri.scheme != "file") {
|
||||
// a content:// URI scheme is resolved via the contentResolver
|
||||
val contentResolver = context.appContext.contentResolver
|
||||
// Get file size from URI, or set to null
|
||||
val fileSize =
|
||||
contentResolver.query(fileUri, null, null, null, null)?.use { cursor ->
|
||||
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
if (sizeIndex != -1 && cursor.moveToFirst()) cursor.getLong(
|
||||
sizeIndex
|
||||
) else null
|
||||
}
|
||||
useChunkedEncoding = useChunkedEncoding ||
|
||||
fileSize == null // Use chunked encoding if file size is unknown
|
||||
if (mimeType.isEmpty()) {
|
||||
resolvedMimeType = getMimeType(contentResolver, fileUri)
|
||||
}
|
||||
// Get InputStream from URI
|
||||
val fileInputStream = contentResolver.openInputStream(fileUri)
|
||||
if (fileInputStream == null) {
|
||||
val message = "Could not open input stream for URI: $fileUri"
|
||||
Log.w(TAG, message)
|
||||
taskException = TaskException(
|
||||
ExceptionType.fileSystem,
|
||||
description = message
|
||||
)
|
||||
return TaskStatus.failed
|
||||
}
|
||||
Log.v(TAG, "Using InputStream from URI $fileUri")
|
||||
Pair(fileSize, fileInputStream)
|
||||
} else {
|
||||
// a file:// Uri scheme is interpreted as a regular file path
|
||||
val file = fileUri.toFile()
|
||||
val fileSize = file.length()
|
||||
if (mimeType.isEmpty()) {
|
||||
resolvedMimeType = getMimeType(fileUri.toString())
|
||||
}
|
||||
Log.v(TAG, "Using FileInputStream from URI $fileUri")
|
||||
Pair(fileSize, FileInputStream(file))
|
||||
}
|
||||
} else {
|
||||
val file = File(pathOrUriString)
|
||||
if (!file.exists() || !file.isFile) {
|
||||
Log.w(TAG, "File at $pathOrUriString does not exist")
|
||||
taskException = TaskException(
|
||||
ExceptionType.fileSystem,
|
||||
description = "File to upload does not exist: $pathOrUriString"
|
||||
)
|
||||
return TaskStatus.failed
|
||||
}
|
||||
if (mimeType.isEmpty()) {
|
||||
resolvedMimeType = getMimeType(file.path)
|
||||
}
|
||||
Pair(file.length(), FileInputStream(file))
|
||||
}
|
||||
// we now have a possible content length and InputStream for this file
|
||||
if (!useChunkedEncoding && fileSize == null) {
|
||||
val message = "Could not determine file size for $pathOrUriString"
|
||||
Log.w(TAG, message)
|
||||
taskException = TaskException(
|
||||
ExceptionType.fileSystem,
|
||||
description = message
|
||||
)
|
||||
return TaskStatus.failed
|
||||
}
|
||||
// determine the file name
|
||||
val name = if (fileUri != null) {
|
||||
getFileNameFromUri(fileUri) ?: "unknown"
|
||||
} else {
|
||||
File(pathOrUriString).name
|
||||
}
|
||||
contentDispositionStrings.add(
|
||||
"Content-Disposition: form-data; name=\"${browserEncode(fileField)}\"; " +
|
||||
"filename=\"${browserEncode(name)}\"$lineFeed"
|
||||
)
|
||||
if (filesData.size == 1 && task.taskType != "MultiUploadTask") {
|
||||
// only for single file uploads do we set the task's filename property
|
||||
task = task.copyWith(
|
||||
filename = if (fileUri != null) UriUtils.pack(
|
||||
name,
|
||||
fileUri
|
||||
) else name
|
||||
)
|
||||
}
|
||||
contentTypeStrings.add("Content-Type: $resolvedMimeType$lineFeed$lineFeed")
|
||||
fileLengthsOrStreams.add(Pair(fileSize, inputStream))
|
||||
} catch (_: Exception) {
|
||||
val message =
|
||||
"Could not open file or determine file size for $pathOrUriString"
|
||||
Log.w(TAG, message)
|
||||
taskException = TaskException(
|
||||
ExceptionType.fileSystem,
|
||||
description = message
|
||||
)
|
||||
return TaskStatus.failed
|
||||
}
|
||||
}
|
||||
|
||||
// setup the connection
|
||||
connection.setRequestProperty("Accept-Charset", "UTF-8")
|
||||
connection.setRequestProperty("Connection", "Keep-Alive")
|
||||
connection.setRequestProperty("Cache-Control", "no-cache")
|
||||
connection.setRequestProperty(
|
||||
"Content-Type", "multipart/form-data; boundary=$boundary"
|
||||
)
|
||||
if (!useChunkedEncoding) {
|
||||
// Calculate total content length only if not using chunked encoding
|
||||
val fileDataLength =
|
||||
contentDispositionStrings.sumOf { string: String -> lengthInBytes(string) } +
|
||||
contentTypeStrings.sumOf { string: String -> string.length } +
|
||||
fileLengthsOrStreams.sumOf { pair ->
|
||||
pair.first ?: 0
|
||||
} + separator.length * contentDispositionStrings.size + 2
|
||||
val contentLength =
|
||||
lengthInBytes(fieldsString) + "--$boundary$lineFeed".length + fileDataLength
|
||||
determineRunInForeground(task, contentLength)
|
||||
context.updateEstimatedNetworkBytes(0L, contentLength)
|
||||
connection.setRequestProperty("Content-Length", contentLength.toString())
|
||||
connection.setFixedLengthStreamingMode(contentLength)
|
||||
} else {
|
||||
determineRunInForeground(task, 1024 * 1024 * 20) // assume at least 20MB
|
||||
connection.setChunkedStreamingMode(0) // Use default chunk size
|
||||
}
|
||||
connection.useCaches = false
|
||||
// transfer the bytes
|
||||
return withContext(Dispatchers.IO) {
|
||||
DataOutputStream(connection.outputStream).use { outputStream ->
|
||||
val writer = outputStream.writer()
|
||||
// write form fields
|
||||
writer.append(fieldsString).append("--${boundary}").append(lineFeed)
|
||||
// write each file
|
||||
for (i in filesData.indices) {
|
||||
fileLengthsOrStreams[i].second.use { inputStream ->
|
||||
if (inputStream != null) {
|
||||
writer.append(contentDispositionStrings[i])
|
||||
.append(contentTypeStrings[i]).flush()
|
||||
val transferBytesResult =
|
||||
transferBytes(
|
||||
inputStream,
|
||||
outputStream,
|
||||
fileLengthsOrStreams[i].first ?: 0,
|
||||
task
|
||||
)
|
||||
if (transferBytesResult == TaskStatus.complete) {
|
||||
if (i < filesData.size - 1) {
|
||||
writer.append(separator)
|
||||
} else
|
||||
writer.append(terminator)
|
||||
writer.flush()
|
||||
} else {
|
||||
return@withContext transferBytesResult
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "No input stream for ${filesData[i].first}")
|
||||
taskException = TaskException(
|
||||
ExceptionType.fileSystem,
|
||||
description = "No input stream for ${filesData[i].first}"
|
||||
)
|
||||
return@withContext TaskStatus.failed
|
||||
}
|
||||
}
|
||||
}
|
||||
writer.close()
|
||||
}
|
||||
return@withContext TaskStatus.complete
|
||||
}
|
||||
} finally {
|
||||
for (pair in fileLengthsOrStreams) {
|
||||
try {
|
||||
pair.second?.close()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file name for the [uri] using the [ContentResolver], or the last path segment,
|
||||
* or null
|
||||
*/
|
||||
private fun getFileNameFromUri(uri: Uri): String? {
|
||||
if (uri.scheme == "content") {
|
||||
val cursor = context.appContext.contentResolver.query(uri, null, null, null, null)
|
||||
cursor?.use {
|
||||
if (it.moveToFirst()) {
|
||||
val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (nameIndex != -1) {
|
||||
return it.getString(nameIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return uri.lastPathSegment
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the mime type for the [uri] using the [ContentResolver] or extension
|
||||
*/
|
||||
fun getMimeType(contentResolver: ContentResolver, uri: Uri): String {
|
||||
// Try to get it directly from ContentResolver
|
||||
var mimeType = contentResolver.getType(uri)
|
||||
if (mimeType != null) return mimeType
|
||||
// Try to infer it from the URL file extension if available
|
||||
return getMimeType(uri.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get mimeType from extension
|
||||
*/
|
||||
private fun getMimeType(url: String): String {
|
||||
var type: String? = null
|
||||
val extension = android.webkit.MimeTypeMap.getFileExtensionFromUrl(url)
|
||||
if (extension != null) {
|
||||
type = android.webkit.MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
|
||||
}
|
||||
return type ?: "application/octet-stream"
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Extract the response's body content as a String, or null if unable, and store
|
||||
* in [responseBody]
|
||||
*/
|
||||
private fun extractResponseBody(connection: HttpURLConnection) {
|
||||
try {
|
||||
responseBody = connection.inputStream.bufferedReader().readText()
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Could not read response body from httpResponseCode ${connection.responseCode}: $e"
|
||||
)
|
||||
}
|
||||
responseBody = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the multipart entry for one field name/value pair
|
||||
*/
|
||||
private fun fieldEntry(name: String, value: String): String {
|
||||
return "--$boundary$lineFeed${headerForField(name, value)}$value$lineFeed"
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the header string for a field
|
||||
*
|
||||
* The return value is guaranteed to contain only ASCII characters
|
||||
*/
|
||||
private fun headerForField(name: String, value: String): String {
|
||||
var header = "Content-Disposition: form-data; name=\"${browserEncode(name)}\""
|
||||
if (isJsonString(value)) {
|
||||
header = "$header\r\n" +
|
||||
"Content-Type: application/json; charset=utf-8"
|
||||
} else if (!isPlainAscii(value)) {
|
||||
header = "$header\r\n" +
|
||||
"Content-Type: text/plain; charset=utf-8\r\n" +
|
||||
"Content-Transfer-Encoding: binary"
|
||||
}
|
||||
return "$header\r\n\r\n"
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether [string] is composed entirely of ASCII-compatible characters
|
||||
*/
|
||||
private fun isPlainAscii(string: String): Boolean {
|
||||
return asciiOnlyRegEx.matches(string)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether [string] is a JSON formatted string
|
||||
*/
|
||||
private fun isJsonString(string: String): Boolean {
|
||||
return jsonStringRegEx.matches(string)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode [value] in the same way browsers do
|
||||
*/
|
||||
private fun browserEncode(value: String): String {
|
||||
// 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.
|
||||
return value.replace(newlineRegEx, "%0D%0A").replace("\"", "%22")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* InputStream that limits the number of bytes read
|
||||
*/
|
||||
class LimitedInputStream(
|
||||
private val inputStream: InputStream,
|
||||
private val limit: Long
|
||||
) : InputStream() {
|
||||
|
||||
private var bytesRead: Long = 0
|
||||
|
||||
override fun read(): Int {
|
||||
if (bytesRead >= limit) {
|
||||
return -1 // End of stream
|
||||
}
|
||||
val result = inputStream.read()
|
||||
if (result != -1) {
|
||||
bytesRead++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override fun read(b: ByteArray, off: Int, len: Int): Int {
|
||||
if (bytesRead >= limit) {
|
||||
return -1
|
||||
}
|
||||
// Adjust length to not exceed limit
|
||||
val remainingBytes: Long = limit - bytesRead
|
||||
val maxBytesToRead =
|
||||
minOf(len.toLong(), remainingBytes).toInt()
|
||||
val result = inputStream.read(b, off, maxBytesToRead)
|
||||
|
||||
if (result != -1) {
|
||||
bytesRead += result
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.bbflight.background_downloader
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.WorkerParameters
|
||||
|
||||
class UploadTaskWorker(context: Context, params: WorkerParameters) :
|
||||
TaskWorker(context, params) {
|
||||
|
||||
override fun createRunner(): TaskRunner {
|
||||
return UploadTaskRunner(this)
|
||||
}
|
||||
}
|
||||
+991
@@ -0,0 +1,991 @@
|
||||
package com.bbflight.background_downloader
|
||||
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.ext.SdkExtensions
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
|
||||
object UriUtils {
|
||||
|
||||
/**
|
||||
* Packs [filename] and [uri] into a single String.
|
||||
*
|
||||
* Use [unpack] to retrieve the filename and uri from the packed String.
|
||||
*/
|
||||
fun pack(filename: String, uri: Uri): String = ":::$filename::::::$uri:::"
|
||||
|
||||
/**
|
||||
* Unpacks [packedString] into a filename and uri. If this is not a packed
|
||||
* string, returns the original [packedString] as the filename and null.
|
||||
*/
|
||||
fun unpack(packedString: String): Pair<String?, Uri?> {
|
||||
val regex = Regex(":::([\\s\\S]*?)::::::([\\s\\S]*?):::")
|
||||
val match = regex.find(packedString)
|
||||
|
||||
return if (match != null && match.groupValues.size == 3) {
|
||||
val filename = match.groupValues[1]
|
||||
val uriString = match.groupValues[2]
|
||||
val uri = Uri.parse(uriString)
|
||||
val scheme = uri?.scheme
|
||||
Pair(filename, if (scheme?.isNotEmpty() == true) uri else null)
|
||||
} else {
|
||||
val uri = Uri.parse(packedString)
|
||||
val scheme = uri?.scheme
|
||||
if (scheme?.isNotEmpty() == true) Pair(null, uri)
|
||||
else Pair(packedString, null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Uri represented by [value], or null if the String is not a
|
||||
* valid Uri or packed Uri string.
|
||||
*
|
||||
* [value] should be a full Uri string, or a packed String containing
|
||||
* a Uri (see [pack]).
|
||||
*/
|
||||
fun uriFromStringValue(value: String): Uri? {
|
||||
val (_, uri) = unpack(value)
|
||||
return uri
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if [value] is a valid Uri or packed Uri string.
|
||||
*
|
||||
* [value] should be a full Uri string, or a packed String containing
|
||||
* a Uri (see [pack]).
|
||||
*/
|
||||
fun containsUri(value: String): Boolean = uriFromStringValue(value) != null
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles method calls from Flutter related to file and directory picking/creation.
|
||||
*/
|
||||
class UriUtilsMethodCallHelper(private val plugin: BDPlugin) : MethodCallHandler {
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
val activity = plugin.activity
|
||||
if (activity == null) {
|
||||
result.error("NO_ACTIVITY", "No activity found", null)
|
||||
return
|
||||
}
|
||||
|
||||
when (call.method) {
|
||||
"pickDirectory" -> {
|
||||
/**
|
||||
* results in selected directory URI (or null) for the [SharedStorage] starting location, which
|
||||
* may be null for no selection. If persistedUriPermission is true, the URI will be persisted
|
||||
*
|
||||
* Arguments are startLocationOrdinal (of [SharedStorage]) and persistedUriPermission (bool)
|
||||
*/
|
||||
val args = call.arguments as? List<*>
|
||||
val startLocationOrdinal = args?.get(0) as Int?
|
||||
val startLocationUriString = args?.get(1) as? String
|
||||
val persistedUriPermission = args?.get(2) as? Boolean == true
|
||||
val startLocation =
|
||||
if (startLocationOrdinal != null) SharedStorage.entries[startLocationOrdinal] else null
|
||||
val startLocationUri = startLocationUriString?.toUri()
|
||||
|
||||
if (!DirectoryPicker.pickDirectory(
|
||||
activity,
|
||||
startLocation,
|
||||
startLocationUri,
|
||||
persistedUriPermission,
|
||||
result
|
||||
)
|
||||
) {
|
||||
result.error(
|
||||
"PICK_DIRECTORY_FAILED",
|
||||
"Failed to launch directory picker",
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
"pickFiles" -> {
|
||||
/**
|
||||
* Results in selected file URI(s) for the [SharedStorage] starting location, which
|
||||
* may be null for no selection. If allowedExtensions is not null, only files with the
|
||||
* given extensions will be allowed. If multipleAllowed is true, multiple files can be
|
||||
* selected. If persistedUriPermission is true, the URI will be persisted.
|
||||
*
|
||||
* Arguments are startLocationOrdinal (of [SharedStorage]), allowedExtensions (list of
|
||||
* strings, or null), multipleAllowed (bool), and persistedUriPermission (bool).
|
||||
*/
|
||||
val args = call.arguments as? List<*>
|
||||
val startLocationOrdinal = args?.get(0) as Int?
|
||||
val startLocationUriString = args?.get(1) as? String
|
||||
val allowedExtensionsAnyList = args?.get(2) as? List<*>
|
||||
val allowedExtensions = allowedExtensionsAnyList?.map { it as String }
|
||||
val multipleAllowed = args?.get(3) as? Boolean == true
|
||||
val persistedUriPermission = args?.get(4) as? Boolean == true
|
||||
|
||||
val startLocation =
|
||||
if (startLocationOrdinal != null) SharedStorage.entries[startLocationOrdinal] else null
|
||||
val startLocationUri = startLocationUriString?.toUri()
|
||||
|
||||
if (startLocation == SharedStorage.images || startLocation == SharedStorage.video) {
|
||||
if (!FilePicker.pickMedia(
|
||||
activity,
|
||||
startLocation,
|
||||
multipleAllowed,
|
||||
persistedUriPermission,
|
||||
result
|
||||
)
|
||||
) {
|
||||
result.error("PICK_FILES_FAILED", "Failed to launch media picker", null)
|
||||
}
|
||||
} else {
|
||||
if (!FilePicker.pickFiles(
|
||||
activity,
|
||||
startLocation,
|
||||
startLocationUri,
|
||||
allowedExtensions,
|
||||
multipleAllowed,
|
||||
persistedUriPermission,
|
||||
result
|
||||
)
|
||||
) {
|
||||
result.error("PICK_FILES_FAILED", "Failed to launch file picker", null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"createDirectory" -> {
|
||||
/**
|
||||
* Creates a new directory with the given name inside the specified parent directory
|
||||
* URI. Results in the URI of the new directory
|
||||
*
|
||||
* Arguments are parentDirectoryUri (string), newDirectoryName (string), and
|
||||
* persistedUriPermission (bool).
|
||||
*/
|
||||
val args = call.arguments as? List<*>
|
||||
val parentDirectoryUriString = args?.get(0) as? String
|
||||
val newDirectoryName = args?.get(1) as? String
|
||||
val persistedUriPermission = args?.get(2) as? Boolean == true
|
||||
|
||||
if (parentDirectoryUriString == null || newDirectoryName == null) {
|
||||
result.error(
|
||||
"INVALID_ARGUMENTS",
|
||||
"Parent directory URI and new directory name are required",
|
||||
null
|
||||
)
|
||||
return
|
||||
}
|
||||
val parentDirectoryUri = Uri.parse(parentDirectoryUriString)
|
||||
DirectoryCreator.createDirectory(
|
||||
activity,
|
||||
parentDirectoryUri,
|
||||
newDirectoryName,
|
||||
persistedUriPermission,
|
||||
result
|
||||
)
|
||||
}
|
||||
|
||||
"getFileBytes" -> {
|
||||
val uriString = call.arguments as? String
|
||||
if (uriString == null) {
|
||||
result.error("INVALID_ARGUMENTS", "URI string is required", null)
|
||||
return
|
||||
}
|
||||
val fileBytes = getFile(activity, Uri.parse(uriString))
|
||||
if (fileBytes != null) {
|
||||
result.success(fileBytes)
|
||||
} else {
|
||||
result.error("GET_FILE_FAILED", "Failed to get file", null)
|
||||
}
|
||||
}
|
||||
|
||||
"copyFile" -> {
|
||||
val args = call.arguments as? List<*>
|
||||
val sourceUriString = args?.get(0) as? String
|
||||
val destinationUriString = args?.get(1) as? String
|
||||
copyFile(activity, sourceUriString, destinationUriString, result)
|
||||
}
|
||||
|
||||
"moveFile" -> {
|
||||
val args = call.arguments as? List<*>
|
||||
val sourceUriString = args?.get(0) as? String
|
||||
val destinationUriString = args?.get(1) as? String
|
||||
moveFile(activity, sourceUriString, destinationUriString, result)
|
||||
}
|
||||
|
||||
"deleteFile" -> {
|
||||
val uriString = call.arguments as? String
|
||||
if (uriString == null) {
|
||||
result.error("INVALID_ARGUMENTS", "URI string is required", null)
|
||||
return
|
||||
}
|
||||
val uri = Uri.parse(uriString)
|
||||
if (uri.scheme == "content") {
|
||||
val docFile = DocumentFile.fromSingleUri(activity, uri)
|
||||
if (docFile != null && docFile.delete()) {
|
||||
result.success(null)
|
||||
} else {
|
||||
if (docFile == null) {
|
||||
result.error("DELETE_FILE_FAILED", "File at $uri does not exist", null)
|
||||
} else {
|
||||
result.error("DELETE_FILE_FAILED", "Failed to delete file", null)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (uri.scheme == "file") {
|
||||
val file = File(uri.path!!)
|
||||
if (file.delete()) {
|
||||
result.success(null)
|
||||
} else {
|
||||
result.error("DELETE_FILE_FAILED", "Failed to delete file", null)
|
||||
}
|
||||
return
|
||||
}
|
||||
result.error("DELETE_FILE_FAILED", "Invalid URI: $uri", null)
|
||||
}
|
||||
|
||||
"openFile" -> {
|
||||
/**
|
||||
* Opens the file at the given URI.
|
||||
*
|
||||
* Arguments are uriString (string) and mimeType (string, nullable).
|
||||
*/
|
||||
val args = call.arguments as? List<*>
|
||||
val uriString = args?.get(0) as? String
|
||||
val mimeType = args?.get(1) as? String
|
||||
|
||||
if (uriString == null) {
|
||||
result.error("INVALID_ARGUMENTS", "URI string is required", null)
|
||||
return
|
||||
}
|
||||
|
||||
if (!doOpenFile(activity, uriString, mimeType ?: getMimeType(uriString))) {
|
||||
result.error("OPEN_FILE_FAILED", "Failed to open file", null)
|
||||
}
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the file content as a byte array for a given URI.
|
||||
*
|
||||
* @param context The application context.
|
||||
* @param uri The URI of the file.
|
||||
* @return The file content as a byte array, or null if an error occurred.
|
||||
*/
|
||||
private fun getFile(context: Context, uri: Uri): ByteArray? {
|
||||
return try {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
} catch (e: IOException) {
|
||||
Log.e(BDPlugin.TAG, "Error reading file: $uri", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the file from the source URI to the destination URI. Handles file:// and content:// schemes
|
||||
* for both source and destination.
|
||||
*/
|
||||
private fun copyFile(
|
||||
activity: Activity,
|
||||
sourceUriString: String?,
|
||||
destinationUriString: String?,
|
||||
result: MethodChannel.Result
|
||||
) {
|
||||
if (sourceUriString == null || destinationUriString == null) {
|
||||
result.error(
|
||||
"INVALID_ARGUMENTS",
|
||||
"Source and destination URI strings are required",
|
||||
null
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val sourceUri = Uri.parse(sourceUriString)
|
||||
val destinationUri = Uri.parse(destinationUriString)
|
||||
try {
|
||||
activity.contentResolver?.openInputStream(sourceUri)?.use { input ->
|
||||
activity.contentResolver?.openOutputStream(destinationUri)?.use { output ->
|
||||
input.copyTo(output)
|
||||
} ?: result.error(
|
||||
"COPY_FAILED",
|
||||
"Failed to open output stream for destination URI",
|
||||
null
|
||||
)
|
||||
} ?: result.error("COPY_FAILED", "Failed to open input stream for source URI", null)
|
||||
|
||||
result.success(destinationUriString) // Return the destination URI as a String
|
||||
} catch (e: Exception) {
|
||||
result.error("COPY_FAILED", "Error copying file: ${e.message}", null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to move a file using [DocumentsContract.moveDocument] if possible (API >= 24 and content URIs),
|
||||
* otherwise falls back to a copy-and-delete strategy. Handles file:// and content:// schemes
|
||||
* for both source and destination.
|
||||
*/
|
||||
private fun moveFile(
|
||||
activity: Activity,
|
||||
sourceUriString: String?,
|
||||
destinationUriString: String?,
|
||||
result: MethodChannel.Result
|
||||
) {
|
||||
if (sourceUriString == null || destinationUriString == null) {
|
||||
result.error(
|
||||
"INVALID_ARGUMENTS",
|
||||
"Source and destination URI strings are required",
|
||||
null
|
||||
)
|
||||
return
|
||||
}
|
||||
val sourceUri = Uri.parse(sourceUriString)
|
||||
val destinationUri = Uri.parse(destinationUriString)
|
||||
// Try using DocumentsContract.moveDocument first (if applicable).
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && sourceUri.scheme == "content" && destinationUri.scheme == "content") {
|
||||
try {
|
||||
val sourceDocument = DocumentFile.fromSingleUri(activity, sourceUri)
|
||||
if (sourceDocument == null || !sourceDocument.exists()) {
|
||||
result.error("MOVE_FAILED", "Source file does not exist", null)
|
||||
return
|
||||
}
|
||||
|
||||
val destinationParent = DocumentFile.fromTreeUri(
|
||||
activity, destinationUri.buildUpon().path(
|
||||
destinationUri.pathSegments.dropLast(1).joinToString("/")
|
||||
).build()
|
||||
) ?: DocumentFile.fromSingleUri(
|
||||
activity, destinationUri.buildUpon().path(
|
||||
destinationUri.pathSegments.dropLast(1).joinToString("/")
|
||||
).build()
|
||||
)
|
||||
if (destinationParent == null || !destinationParent.isDirectory) {
|
||||
result.error(
|
||||
"MOVE_FAILED",
|
||||
"Destination directory does not exist or is invalid",
|
||||
null
|
||||
)
|
||||
return
|
||||
}
|
||||
val movedDocument = DocumentsContract.moveDocument(
|
||||
activity.contentResolver,
|
||||
sourceUri,
|
||||
sourceDocument.parentFile!!.uri,
|
||||
destinationParent.uri
|
||||
)
|
||||
if (movedDocument != null) {
|
||||
val renamedFile = DocumentsContract.renameDocument(
|
||||
activity.contentResolver,
|
||||
movedDocument,
|
||||
destinationUri.lastPathSegment.toString()
|
||||
)
|
||||
result.success(renamedFile.toString())
|
||||
return
|
||||
} else {
|
||||
result.error("MOVE_FAILED", "Failed to move file using DocumentsContract", null)
|
||||
return
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.i(
|
||||
BDPlugin.TAG,
|
||||
"Error moving file (DocumentsContract) from $sourceUri to $destinationUri",
|
||||
e
|
||||
)
|
||||
}
|
||||
}
|
||||
// Fallback: Copy and Delete (works for mixed schemes and older Android versions).
|
||||
try {
|
||||
val success = copyForMove(
|
||||
activity,
|
||||
sourceUri,
|
||||
destinationUri,
|
||||
result
|
||||
) // perform the copy operation
|
||||
if (success) {
|
||||
var deleteSuccess = false
|
||||
if (sourceUri.scheme == "content") {
|
||||
val sourceDocument = DocumentFile.fromSingleUri(activity, sourceUri)
|
||||
// Use DocumentsContract.deleteDocument if available.
|
||||
if (sourceDocument != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
deleteSuccess = try {
|
||||
DocumentsContract.deleteDocument(
|
||||
activity.contentResolver,
|
||||
sourceUri
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
// Try to delete using the document (if deleteDocument fails)
|
||||
sourceDocument.delete()
|
||||
}
|
||||
} else if (sourceDocument != null) {
|
||||
deleteSuccess = sourceDocument.delete()
|
||||
}
|
||||
} else if (sourceUri.scheme == "file") {
|
||||
val sourceFile = File(sourceUri.path!!)
|
||||
deleteSuccess = sourceFile.delete()
|
||||
}
|
||||
if (deleteSuccess) {
|
||||
result.success(destinationUriString)
|
||||
} else {
|
||||
result.error("MOVE_FAILED", "Failed to delete the source", null)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
result.error("MOVE_FAILED", "Error moving file: ${e.message}", null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Private helper function to perform copy, to be used in move.
|
||||
* returns true if successful
|
||||
*/
|
||||
private fun copyForMove(
|
||||
activity: Activity,
|
||||
sourceUri: Uri,
|
||||
destinationUri: Uri,
|
||||
result: MethodChannel.Result
|
||||
): Boolean {
|
||||
try {
|
||||
if (destinationUri.scheme == "file") {
|
||||
val destFile = File(destinationUri.path!!)
|
||||
if (!destFile.parentFile!!.exists()) {
|
||||
if (!destFile.parentFile!!.mkdirs()) {
|
||||
result.error(
|
||||
"MOVE_FAILED",
|
||||
"Could not create destination directory at ${destFile.parentFile!!.absolutePath}",
|
||||
null
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
activity.contentResolver?.openInputStream(sourceUri)?.use { input ->
|
||||
activity.contentResolver?.openOutputStream(destinationUri)?.use { output ->
|
||||
input.copyTo(output)
|
||||
} ?: run {
|
||||
result.error(
|
||||
"MOVE_FAILED",
|
||||
"Failed to open output stream for destination URI",
|
||||
null
|
||||
)
|
||||
return false
|
||||
}
|
||||
} ?: run {
|
||||
result.error("MOVE_FAILED", "Failed to open input stream for source URI", null)
|
||||
return false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
result.error("MOVE_FAILED", "Error copying file: ${e.message}", null)
|
||||
return false
|
||||
}
|
||||
return true // copy was successful
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Helper class for launching the Android file picker to select a directory.
|
||||
*/
|
||||
object DirectoryPicker {
|
||||
const val TAG = "DirectoryPicker"
|
||||
private var pendingResult: MethodChannel.Result? = null
|
||||
private var persistedUriPermission: Boolean = false
|
||||
private const val REQUEST_CODE_PICK_DIRECTORY = 165465106
|
||||
|
||||
|
||||
/**
|
||||
* Launches the Android file picker to select a directory.
|
||||
*
|
||||
* @param activity The current activity.
|
||||
* @param startLocation The shared storage location to start the picker in (ignored on API levels below Q).
|
||||
* @param result The MethodChannel result to complete when the picker is closed.
|
||||
*
|
||||
* Returns true if the picker was launched successfully, false otherwise.
|
||||
*
|
||||
* The result of the picker is handled in [handleActivityResult], and is either
|
||||
* a URI string, null (if the picker was cancelled) or an error
|
||||
*/
|
||||
fun pickDirectory(
|
||||
activity: Activity,
|
||||
startLocation: SharedStorage?,
|
||||
startLocationUri: Uri?,
|
||||
persistedUriPermission: Boolean = false,
|
||||
result: MethodChannel.Result
|
||||
): Boolean {
|
||||
if (pendingResult != null) {
|
||||
Log.w(TAG, "Directory picker already in progress")
|
||||
return false
|
||||
}
|
||||
pendingResult = result
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
|
||||
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
if (persistedUriPermission) {
|
||||
flags = flags or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
|
||||
}
|
||||
// Set the starting directory (for API 26+).
|
||||
if ((startLocation != null || startLocationUri != null) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
putExtra(
|
||||
DocumentsContract.EXTRA_INITIAL_URI,
|
||||
if (startLocationUri != null) startLocationUri else getInitialDirectoryUri(
|
||||
startLocation!!
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
this.persistedUriPermission = persistedUriPermission // for later reference
|
||||
activity.startActivityForResult(intent, REQUEST_CODE_PICK_DIRECTORY)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the result from the directory picker activity, buy setting the [pendingResult] to the
|
||||
* result value. Returns null if the request was cancelled by the user, and errors if something
|
||||
* unusual happened
|
||||
*
|
||||
* @param requestCode The request code passed to startActivityForResult().
|
||||
* @param resultCode The result code returned by the child activity.
|
||||
* @param data An Intent, which can return result data to the caller.
|
||||
* @return True if the result was handled, false otherwise.
|
||||
*/
|
||||
fun handleActivityResult(
|
||||
context: Context,
|
||||
requestCode: Int,
|
||||
resultCode: Int,
|
||||
data: Intent?
|
||||
): Boolean {
|
||||
if (requestCode == REQUEST_CODE_PICK_DIRECTORY) {
|
||||
if (resultCode == Activity.RESULT_OK) {
|
||||
val directoryUri = data?.data
|
||||
if (persistedUriPermission && directoryUri != null) {
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
directoryUri,
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
)
|
||||
}
|
||||
pendingResult?.success(directoryUri?.toString())
|
||||
} else {
|
||||
pendingResult?.success(null)
|
||||
}
|
||||
pendingResult = null // cancelled
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper class for launching the Android file picker to select one or more files.
|
||||
*/
|
||||
object FilePicker {
|
||||
const val TAG = "FilePicker"
|
||||
private var pendingResult: MethodChannel.Result? = null
|
||||
private var multipleAllowed: Boolean = false
|
||||
private var persistedUriPermission: Boolean = false
|
||||
private const val REQUEST_CODE_PICK_FILES = 265465106
|
||||
private const val REQUEST_CODE_PICK_MEDIA = 36546510
|
||||
|
||||
/**
|
||||
* Launches the Android file picker to select one or more files. Only read access is granted
|
||||
* to these files
|
||||
*
|
||||
* @param activity The current activity.
|
||||
* @param startLocation The shared storage location to start the picker in (ignored on API levels below Q).
|
||||
* @param allowedExtensions A list of allowed file extensions (without the dot), e.g., ["pdf", "jpg", "png"]. If null, all file types are allowed.
|
||||
* @param multipleAllowed Whether to allow the user to select multiple files.
|
||||
* @param persistedUriPermission Whether to persist the URI permission across device reboots.
|
||||
* @param result The MethodChannel result to complete when the picker is closed.
|
||||
*
|
||||
* Returns true if the picker was launched successfully, false otherwise.
|
||||
*
|
||||
* The result posted back is either a list of URIs as a String, or null (if the user has
|
||||
* cancelled the picker) or an error
|
||||
*/
|
||||
fun pickFiles(
|
||||
activity: Activity,
|
||||
startLocation: SharedStorage?,
|
||||
startLocationUri: Uri?,
|
||||
allowedExtensions: List<String>?,
|
||||
multipleAllowed: Boolean = false,
|
||||
persistedUriPermission: Boolean = false,
|
||||
result: MethodChannel.Result
|
||||
): Boolean {
|
||||
if (pendingResult != null) {
|
||||
Log.w(TAG, "File picker already in progress")
|
||||
return false
|
||||
}
|
||||
this.multipleAllowed = multipleAllowed
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multipleAllowed)
|
||||
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
if (persistedUriPermission) {
|
||||
flags = flags or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
|
||||
}
|
||||
|
||||
if (!allowedExtensions.isNullOrEmpty()) {
|
||||
val mimeTypes = extensionsToMimeTypes(allowedExtensions)
|
||||
if (mimeTypes.size == 1) {
|
||||
type = mimeTypes[0]
|
||||
} else {
|
||||
type = "*/*"
|
||||
putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
|
||||
}
|
||||
} else {
|
||||
type = "*/*" // Allow all file types if no extensions are specified
|
||||
}
|
||||
|
||||
// Set the starting directory (for API 26+).
|
||||
if ((startLocation != null || startLocationUri != null) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
putExtra(
|
||||
DocumentsContract.EXTRA_INITIAL_URI,
|
||||
if (startLocationUri != null) startLocationUri else getInitialDirectoryUri(
|
||||
startLocation!!
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
this.persistedUriPermission = persistedUriPermission // for later reference
|
||||
this.multipleAllowed = multipleAllowed // for later reference
|
||||
pendingResult = result
|
||||
activity.startActivityForResult(intent, REQUEST_CODE_PICK_FILES)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches the Android photo picker to select one or more media files (images or videos).
|
||||
*
|
||||
* @param activity The current activity.
|
||||
* @param startLocation The shared storage location to start the picker in (images or video).
|
||||
* @param multipleAllowed Whether to allow the user to select multiple files.
|
||||
* @param persistedUriPermission Whether to persist the URI permission across device reboots.
|
||||
* @param result The MethodChannel result to complete when the picker is closed.
|
||||
*
|
||||
* Returns true if the picker was launched successfully, false otherwise.
|
||||
*
|
||||
* The result posted back is either a list of URIs as a String, or null (if the user has
|
||||
* cancelled the picker) or an error
|
||||
*/
|
||||
fun pickMedia(
|
||||
activity: Activity,
|
||||
startLocation: SharedStorage,
|
||||
multipleAllowed: Boolean = false,
|
||||
persistedUriPermission: Boolean = false,
|
||||
result: MethodChannel.Result
|
||||
): Boolean {
|
||||
if (pendingResult != null) {
|
||||
Log.w(TAG, "Media picker already in progress")
|
||||
return false
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion(
|
||||
Build.VERSION_CODES.R
|
||||
) >= 2
|
||||
) {
|
||||
// on Android version >R.2 we use the new photo picker
|
||||
val photoPickerIntent = Intent(MediaStore.ACTION_PICK_IMAGES).apply {
|
||||
if (multipleAllowed) {
|
||||
val maxImages = MediaStore.getPickImagesMaxLimit()
|
||||
putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, maxImages)
|
||||
}
|
||||
}
|
||||
photoPickerIntent.apply {
|
||||
if (startLocation == SharedStorage.images) {
|
||||
type = "image/*"
|
||||
} else if (startLocation == SharedStorage.video) {
|
||||
type = "video/*"
|
||||
}
|
||||
}
|
||||
this.persistedUriPermission = persistedUriPermission // for later reference
|
||||
this.multipleAllowed = multipleAllowed // for later reference
|
||||
pendingResult = result
|
||||
activity.startActivityForResult(photoPickerIntent, REQUEST_CODE_PICK_MEDIA)
|
||||
return true
|
||||
} else {
|
||||
// for older Android versions we use the filePicker with image or video extensions
|
||||
return pickFiles(
|
||||
activity,
|
||||
startLocation,
|
||||
null,
|
||||
if (startLocation == SharedStorage.images) listOf(
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"png",
|
||||
"gif",
|
||||
"webp",
|
||||
"bmp",
|
||||
"heic",
|
||||
"heif",
|
||||
"svg"
|
||||
) else listOf("mp4", "webm", "ogg", "webm", "mov", "avi", "mkv"),
|
||||
multipleAllowed,
|
||||
persistedUriPermission,
|
||||
result
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the result from the file picker activity, by setting the [pendingResult] to the
|
||||
* result value. Grants read access only
|
||||
*
|
||||
* @param context The context, needed to persist permissions
|
||||
* @param requestCode The request code passed to startActivityForResult().
|
||||
* @param resultCode The result code returned by the child activity.
|
||||
* @param data An Intent, which can return result data to the caller.
|
||||
* @return True if the result was handled, false otherwise.
|
||||
*/
|
||||
fun handleActivityResult(
|
||||
context: Context,
|
||||
requestCode: Int,
|
||||
resultCode: Int,
|
||||
data: Intent?
|
||||
): Boolean {
|
||||
if (requestCode == REQUEST_CODE_PICK_FILES || requestCode == REQUEST_CODE_PICK_MEDIA) {
|
||||
if (resultCode == Activity.RESULT_OK) {
|
||||
val uris = mutableListOf<String>()
|
||||
if (multipleAllowed && data?.clipData != null) {
|
||||
// Multiple files selected
|
||||
val clipData = data.clipData!!
|
||||
for (i in 0 until clipData.itemCount) {
|
||||
val uri = clipData.getItemAt(i).uri
|
||||
if (persistedUriPermission) {
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
)
|
||||
}
|
||||
uris.add(uri.toString())
|
||||
}
|
||||
} else if (data?.data != null) {
|
||||
// Single file selected
|
||||
val uri = data.data!!
|
||||
if (persistedUriPermission) {
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
)
|
||||
}
|
||||
uris.add(uri.toString())
|
||||
}
|
||||
pendingResult?.success(uris)
|
||||
} else {
|
||||
pendingResult?.success(null) // cancelled
|
||||
}
|
||||
pendingResult = null // Reset the pending result
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Converts a list of file extensions to a list of corresponding MIME types using the provided `getMimeType` function.
|
||||
*
|
||||
* @param extensions The list of file extensions (without the dot).
|
||||
* @return A list of MIME types.
|
||||
*/
|
||||
private fun extensionsToMimeTypes(extensions: List<String>): Array<String> {
|
||||
return extensions.map { extension ->
|
||||
if (extension.startsWith('.'))
|
||||
getMimeType(extension)
|
||||
else getMimeType("file.$extension")
|
||||
}.toSet().toTypedArray()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper class for creating a new directory using the Android SAF or file system.
|
||||
*/
|
||||
object DirectoryCreator {
|
||||
const val TAG = "DirectoryCreator"
|
||||
|
||||
/**
|
||||
* Creates a new directory within the given parent directory.
|
||||
* If the new directory path contains multiple levels, it creates all intermediate directories as needed.
|
||||
*
|
||||
* @param context The application context.
|
||||
* @param parentDirectoryUri The URI of the parent directory (file:// or content://).
|
||||
* @param newDirectoryName The name of the new directory to create (can be a path).
|
||||
* @param persistedUriPermission Whether to persist the URI permission across device reboots.
|
||||
* @param result The MethodChannel result to complete when the operation is finished.
|
||||
*
|
||||
* Returns true if the directory creation was initiated successfully, false otherwise.
|
||||
*
|
||||
* The result posted back is either a URI string, or an error
|
||||
*/
|
||||
fun createDirectory(
|
||||
context: Context,
|
||||
parentDirectoryUri: Uri,
|
||||
newDirectoryName: String,
|
||||
persistedUriPermission: Boolean,
|
||||
result: MethodChannel.Result
|
||||
) {
|
||||
if (parentDirectoryUri.scheme == "file") {
|
||||
// Handle file scheme URIs using standard file operations
|
||||
val parentDir = File(parentDirectoryUri.path!!)
|
||||
if (!parentDir.exists() || !parentDir.isDirectory) {
|
||||
result.error(
|
||||
"INVALID_PARENT_URI",
|
||||
"Invalid or inaccessible parent directory URI",
|
||||
null
|
||||
)
|
||||
return
|
||||
}
|
||||
val sanitizedDirectoryName = newDirectoryName.trim(File.separatorChar)
|
||||
val newDir = File(parentDir, sanitizedDirectoryName)
|
||||
try {
|
||||
if (!newDir.exists()) {
|
||||
if (!newDir.mkdirs()) {
|
||||
result.error(
|
||||
"CREATE_FAILED",
|
||||
"Failed to create directory: ${newDir.absolutePath}",
|
||||
null
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
result.success(Uri.fromFile(newDir).toString())
|
||||
} catch (e: Exception) {
|
||||
result.error(
|
||||
"CREATE_FAILED",
|
||||
"Error creating directory: ${e.message}",
|
||||
null
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// handle content Uri scheme using DocumentFile API
|
||||
val parentDir = DocumentFile.fromTreeUri(context, parentDirectoryUri)
|
||||
if (parentDir == null || !parentDir.exists() || !parentDir.isDirectory) {
|
||||
result.error(
|
||||
"INVALID_PARENT_URI",
|
||||
"Invalid or inaccessible parent directory URI",
|
||||
null
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Sanitize the new directory name by removing leading/trailing path separators
|
||||
val sanitizedDirectoryName = newDirectoryName.trim(File.separatorChar)
|
||||
|
||||
try {
|
||||
var currentDir: DocumentFile = parentDir
|
||||
val directoryPathParts = sanitizedDirectoryName.split(File.separatorChar)
|
||||
for (directoryName in directoryPathParts) {
|
||||
var newDir = currentDir.findFile(directoryName)
|
||||
if (newDir == null || !newDir.exists()) {
|
||||
newDir = currentDir.createDirectory(directoryName)
|
||||
} else if (!newDir.isDirectory) {
|
||||
result.error(
|
||||
"INVALID_PATH",
|
||||
"Invalid path: $directoryName is not a directory",
|
||||
null
|
||||
)
|
||||
return
|
||||
}
|
||||
if (newDir == null) {
|
||||
result.error(
|
||||
"CREATE_FAILED",
|
||||
"Failed to create directory: $directoryName",
|
||||
null
|
||||
)
|
||||
return
|
||||
}
|
||||
currentDir = newDir
|
||||
// Grant permissions to intermediate directories as well
|
||||
if (persistedUriPermission) {
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
newDir.uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
)
|
||||
}
|
||||
}
|
||||
// Return the URI of the final directory
|
||||
result.success(currentDir.uri.toString())
|
||||
} catch (e: Exception) {
|
||||
result.error(
|
||||
"CREATE_FAILED",
|
||||
"Error creating directory: ${e.message}",
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gets the initial directory URI based on the specified shared storage location.
|
||||
*
|
||||
* @param location The shared storage location.
|
||||
* @return The URI of the corresponding directory.
|
||||
*/
|
||||
private fun getInitialDirectoryUri(location: SharedStorage): Uri {
|
||||
return when (location) {
|
||||
SharedStorage.downloads -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
MediaStore.Downloads.EXTERNAL_CONTENT_URI
|
||||
} else {
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||
.toUri()
|
||||
}
|
||||
}
|
||||
|
||||
SharedStorage.images -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
"content://com.android.externalstorage.documents/tree/primary%3APictures".toUri()
|
||||
} else {
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
|
||||
.toUri()
|
||||
}
|
||||
}
|
||||
|
||||
SharedStorage.video -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
"content://com.android.externalstorage.documents/tree/primary%3AMovies".toUri()
|
||||
} else {
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES)
|
||||
.toUri()
|
||||
}
|
||||
}
|
||||
|
||||
SharedStorage.audio -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
"content://com.android.externalstorage.documents/tree/primary%3AMusic".toUri()
|
||||
} else {
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC)
|
||||
.toUri()
|
||||
}
|
||||
}
|
||||
|
||||
SharedStorage.external -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
"content://com.android.externalstorage.documents/tree/secondary%3A".toUri()
|
||||
} else {
|
||||
Environment.getExternalStorageDirectory().toUri()
|
||||
}
|
||||
}
|
||||
|
||||
SharedStorage.files -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
"content://com.android.externalstorage.documents/tree/primary%3A".toUri()
|
||||
} else {
|
||||
Environment.getDataDirectory().toUri()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/WiFi.kt
Vendored
+179
@@ -0,0 +1,179 @@
|
||||
package com.bbflight.background_downloader
|
||||
|
||||
import android.content.Context
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/**
|
||||
* Processes WiFi requirement changes
|
||||
*
|
||||
* requireWiFiQueue:
|
||||
* Each item is a [RequireWiFiChange] data object, and each workInfo
|
||||
* will be either rescheduled (if enqueued) or paused and resumed (if running and
|
||||
* possible) or cancelled and re-enqueued (if running and pause not possible
|
||||
*
|
||||
* reEnqueueQueue:
|
||||
* Each item is a [ReEnqueue] data object represent one task re-enqueue. The task
|
||||
* will be re-enqueued with an appropriate delay
|
||||
*/
|
||||
object WiFi {
|
||||
private val scope = CoroutineScope(Dispatchers.Default)
|
||||
|
||||
private val requireWiFiQueue =
|
||||
Channel<RequireWiFiChange>(capacity = Channel.UNLIMITED)
|
||||
private val reEnqueueQueue = Channel<suspend () -> Unit>(capacity = Channel.UNLIMITED)
|
||||
|
||||
// completed once the current change's re-enqueues are enqueued
|
||||
@Volatile
|
||||
private var batchEnd: CompletableDeferred<Unit>? = null
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
for (change in requireWiFiQueue) {
|
||||
val thisBatchEnd = CompletableDeferred<Unit>()
|
||||
batchEnd = thisBatchEnd
|
||||
try {
|
||||
// wait for the re-enqueues before the next change, with a timeout
|
||||
if (change.execute() && withTimeoutOrNull(15000) { thisBatchEnd.await() } == null) {
|
||||
val clearedTaskIds = synchronized(BDPlugin.tasksToReEnqueue) {
|
||||
BDPlugin.tasksToReEnqueue.map { it.taskId }
|
||||
.also { BDPlugin.tasksToReEnqueue.clear() }
|
||||
}
|
||||
Log.w(
|
||||
BDPlugin.TAG,
|
||||
"WiFi re-enqueue watchdog timeout, cleared taskIds $clearedTaskIds"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(BDPlugin.TAG, "Exception during requireWiFiChange execution: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
for (reEnqueue in reEnqueueQueue) {
|
||||
try {
|
||||
reEnqueue()
|
||||
} catch (e: Exception) {
|
||||
Log.e(BDPlugin.TAG, "Exception during WiFi reEnqueue: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the requireWiFi change request
|
||||
*/
|
||||
suspend fun requireWiFiChange(change: RequireWiFiChange) {
|
||||
requireWiFiQueue.send(change)
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-enqueue this task and associated data. Null signals end of batch
|
||||
*/
|
||||
suspend fun reEnqueue(enqueueItem: EnqueueItem?) {
|
||||
if (enqueueItem != null) {
|
||||
reEnqueueQueue.send { enqueueItem.reEnqueue() }
|
||||
} else {
|
||||
// captured now, so a late signal cannot end a later batch
|
||||
val thisBatchEnd = batchEnd
|
||||
reEnqueueQueue.send { thisBatchEnd?.complete(Unit) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the global WiFi requirement setting
|
||||
*/
|
||||
class RequireWiFiChange(
|
||||
private val applicationContext: Context,
|
||||
private val requireWifi: RequireWiFi,
|
||||
private val rescheduleRunningTasks: Boolean,
|
||||
private val alsoRestartUploads: Boolean
|
||||
) {
|
||||
/**
|
||||
* Execute the change in WiFi requirement and return
|
||||
* true if one or more tasks have been scheduled for re-enqueue
|
||||
*/
|
||||
suspend fun execute(): Boolean {
|
||||
BDPlugin.requireWifi = requireWifi
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext)
|
||||
prefs.edit().apply {
|
||||
putInt(BDPlugin.keyRequireWiFi, requireWifi.ordinal)
|
||||
apply()
|
||||
}
|
||||
val tasksMap = getTaskMap(prefs)
|
||||
val workManager = WorkManager.getInstance(applicationContext)
|
||||
val workInfos = workManager.getWorkInfosByTag(BDPlugin.TAG).get()
|
||||
.filter { !it.state.isFinished }
|
||||
// collected first and all added to tasksToReEnqueue before any is canceled or paused,
|
||||
// so the set can't run empty, which ends the batch, before the last task is added
|
||||
val tasksToCancel = mutableListOf<Task>()
|
||||
val tasksToPause = mutableListOf<Task>()
|
||||
for (workInfo in workInfos) {
|
||||
val tags = workInfo.tags.filter { it.contains("taskId=") }
|
||||
if (tags.isNotEmpty()) {
|
||||
val taskId = tags.first().substring(7)
|
||||
val task = tasksMap[taskId]
|
||||
if (task != null && (task.isDownloadTask() || task.isUploadTask())) {
|
||||
// compare with the constraints the work was enqueued with, which WorkManager
|
||||
// keeps across app restarts, so work enqueued by an earlier process is included
|
||||
if (BDPlugin.taskRequiresWifi(task) != workInfo.constraints.requiresWifi()) {
|
||||
when (workInfo.state) {
|
||||
WorkInfo.State.ENQUEUED -> tasksToCancel.add(task)
|
||||
|
||||
WorkInfo.State.RUNNING -> {
|
||||
if (rescheduleRunningTasks && task.isDownloadTask()) {
|
||||
tasksToPause.add(task)
|
||||
} else if (alsoRestartUploads && task.isUploadTask()) {
|
||||
tasksToCancel.add(task)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BDPlugin.tasksToReEnqueue.addAll(tasksToCancel + tasksToPause)
|
||||
tasksToPause.forEach { BDPlugin.pauseTaskWithId(it.taskId) }
|
||||
for (task in tasksToCancel) {
|
||||
// the cancel's update is intercepted within cancelActiveTaskWithId, so an entry still
|
||||
// there means it sent none: the task finished before it was added, and its entry
|
||||
// would otherwise stay until the timeout
|
||||
if (!BDPlugin.cancelActiveTaskWithId(applicationContext, task.taskId, workManager) ||
|
||||
BDPlugin.tasksToReEnqueue.contains(task)
|
||||
) {
|
||||
BDPlugin.tasksToReEnqueue.remove(task)
|
||||
if (BDPlugin.tasksToReEnqueue.isEmpty()) {
|
||||
WiFi.reEnqueue(null) // nothing left to re-enqueue, so end the batch
|
||||
}
|
||||
}
|
||||
}
|
||||
return tasksToCancel.isNotEmpty() || tasksToPause.isNotEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if these [Constraints] restrict the work to WiFi, as set in [BDPlugin.doEnqueue]
|
||||
*
|
||||
* From API 28 the WiFi requirement is a NetworkRequest, and WorkManager then stores
|
||||
* NetworkType.NOT_REQUIRED as the network type. Before API 28 it is NetworkType.UNMETERED
|
||||
*/
|
||||
private fun Constraints.requiresWifi(): Boolean =
|
||||
(Build.VERSION.SDK_INT >= 28 &&
|
||||
requiredNetworkRequest?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true) ||
|
||||
requiredNetworkType == NetworkType.UNMETERED
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<vector android:height="24dp" android:tint="#000000"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M12,2C6.47,2 2,6.47 2,12s4.47,10 10,10 10,-4.47 10,-10S17.53,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8zM15.59,7L12,10.59 8.41,7 7,8.41 10.59,12 7,15.59 8.41,17 12,13.41 15.59,17 17,15.59 13.41,12 17,8.41z"/>
|
||||
</vector>
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
<vector android:height="24dp" android:tint="#000000"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M5,18h14v2L5,20v-2zM9.6,15.3L5,10.7l2,-1.9 2.6,2.6L17,4l2,2 -9.4,9.3z"/>
|
||||
</vector>
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
<vector android:height="24dp" android:tint="#000000"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M11,15h2v2h-2v-2zM11,7h2v6h-2L11,7zM11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z"/>
|
||||
</vector>
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
<vector android:height="24dp" android:tint="#000000"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M18,15v3H6v-3H4v3c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2v-3H18zM17,11l-1.41,-1.41L13,12.17V4h-2v8.17L8.41,9.59L7,11l5,5L17,11z"/>
|
||||
</vector>
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<vector android:height="24dp" android:tint="#000000"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M18,15v3H6v-3H4v3c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2v-3H18zM7,9l1.41,1.41L11,7.83V16h2V7.83l2.59,2.58L17,9l-5,-5L7,9z"/>
|
||||
</vector>
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<vector android:height="24dp" android:tint="#000000"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z"/>
|
||||
</vector>
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<vector android:height="24dp" android:tint="#000000"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M10,8.64L15.27,12 10,15.36V8.64M8,5v14l11,-7L8,5z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="bg_downloader_cancel">Cancel</string>
|
||||
<string name="bg_downloader_pause">Pause</string>
|
||||
<string name="bg_downloader_resume">Resume</string>
|
||||
<string name="bg_downloader_notification_channel_name">Downloads</string>
|
||||
<string name="bg_downloader_notification_channel_description">Updates on downloads and
|
||||
uploads in progress</string>
|
||||
</resources>
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path
|
||||
name="external-path"
|
||||
path="."/>
|
||||
<external-cache-path
|
||||
name="external-cache-path"
|
||||
path="."/>
|
||||
<external-files-path
|
||||
name="external-files-path"
|
||||
path="."/>
|
||||
<files-path
|
||||
name="files_path"
|
||||
path="."/>
|
||||
<cache-path
|
||||
name="cache-path"
|
||||
path="."/>
|
||||
<external-media-path
|
||||
name="external_media"
|
||||
path="." />
|
||||
</paths>
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package com.bbflight.background_downloader
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
|
||||
class AuthTest {
|
||||
|
||||
private fun createAuth(
|
||||
accessToken: String? = null,
|
||||
accessHeaders: Map<String, String> = emptyMap(),
|
||||
accessQueryParams: Map<String, String> = emptyMap(),
|
||||
accessTokenExpiryTime: Long? = null,
|
||||
refreshToken: String? = null,
|
||||
refreshHeaders: Map<String, String> = emptyMap(),
|
||||
refreshQueryParams: Map<String, String> = emptyMap(),
|
||||
refreshUrl: String? = null,
|
||||
onAuthRawHandle: Long? = null
|
||||
): Auth = Auth(
|
||||
accessToken = accessToken,
|
||||
accessHeaders = accessHeaders,
|
||||
accessQueryParams = accessQueryParams,
|
||||
accessTokenExpiryTime = accessTokenExpiryTime,
|
||||
refreshToken = refreshToken,
|
||||
refreshHeaders = refreshHeaders,
|
||||
refreshQueryParams = refreshQueryParams,
|
||||
refreshUrl = refreshUrl,
|
||||
onAuthRawHandle = onAuthRawHandle
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `getExpandedAccessHeaders replaces tokens correctly`() {
|
||||
val auth = createAuth(
|
||||
accessToken = "access123",
|
||||
refreshToken = "refresh123",
|
||||
accessHeaders = mapOf("Authorization" to "Bearer {accessToken}", "Refresh" to "{refreshToken}")
|
||||
)
|
||||
val expandedHeaders = auth.getExpandedAccessHeaders()
|
||||
assertEquals("Bearer access123", expandedHeaders["Authorization"])
|
||||
assertEquals("refresh123", expandedHeaders["Refresh"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getExpandedAccessQueryParams replaces tokens correctly`() {
|
||||
val auth = createAuth(
|
||||
accessToken = "access456",
|
||||
refreshToken = "refresh456",
|
||||
accessQueryParams = mapOf("token" to "A {accessToken}", "refresh" to "A {refreshToken}")
|
||||
)
|
||||
val expandedParams = auth.getExpandedAccessQueryParams()
|
||||
assertEquals("A access456", expandedParams["token"])
|
||||
assertEquals("A refresh456", expandedParams["refresh"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addOrUpdateQueryParams adds new query params to URL`() {
|
||||
// ignored because we would have to mock Uri.parse
|
||||
// val auth = createAuth()
|
||||
// val url = "https://example.com/api"
|
||||
// val queryParams = mapOf("param1" to "value1", "param2" to "value2")
|
||||
// val updatedUri = auth.addOrUpdateQueryParams(url, queryParams)
|
||||
// assertEquals("https://example.com/api?param1=value1¶m2=value2", updatedUri.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addOrUpdateQueryParams updates existing query params`() {
|
||||
// ignored because we would have to mock Uri.parse
|
||||
// val auth = createAuth()
|
||||
// val url = "https://example.com/api?param1=oldValue"
|
||||
// val queryParams = mapOf("param1" to "newValue")
|
||||
// val updatedUri = auth.addOrUpdateQueryParams(url, queryParams)
|
||||
// assertEquals("https://example.com/api?param1=newValue", updatedUri.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isTokenExpired returns true when token is expired`() {
|
||||
val expiryTime = System.currentTimeMillis() - 10000 // Expired 10 seconds ago
|
||||
val auth = createAuth(accessTokenExpiryTime = expiryTime)
|
||||
assertTrue(auth.isTokenExpired())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isTokenExpired returns false when token is not expired`() {
|
||||
val expiryTime = System.currentTimeMillis() + 60000 // Expires in 1 minute
|
||||
val auth = createAuth(accessTokenExpiryTime = expiryTime)
|
||||
assertFalse(auth.isTokenExpired())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isTokenExpired applies buffer time correctly`() {
|
||||
val expiryTime = System.currentTimeMillis() + 5000 // Expires in 5 seconds
|
||||
val auth = createAuth(accessTokenExpiryTime = expiryTime)
|
||||
assertTrue(auth.isTokenExpired(bufferTime = 7000)) // Buffer time makes it appear expired
|
||||
assertFalse(auth.isTokenExpired(bufferTime = 3000)) // Within buffer, not expired
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasOnAuthCallback returns true when onAuthRawHandle is set`() {
|
||||
val auth = createAuth(onAuthRawHandle = 1234L)
|
||||
assertTrue(auth.hasOnAuthCallback())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasOnAuthCallback returns false when onAuthRawHandle is not set`() {
|
||||
val auth = createAuth(onAuthRawHandle = null)
|
||||
assertFalse(auth.hasOnAuthCallback())
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.bbflight.background_downloader
|
||||
|
||||
//val urlWithContentLength = "https://storage.googleapis.com/approachcharts/test/5MB-test.ZIP"
|
||||
//var task = Task(mapOf("url" to urlWithContentLength, "filename" to "taskFilename.txt"))
|
||||
//
|
||||
//
|
||||
//class HelpersTest {
|
||||
//
|
||||
// @Test
|
||||
// fun parseRange() {
|
||||
// assertEquals(Pair(10L, 20L), parseRange("bytes=10-20"))
|
||||
// assertEquals(Pair(0L, 20L), parseRange("bytes=-20"))
|
||||
// assertEquals(Pair(10L, null), parseRange("bytes=10-"))
|
||||
// assertEquals(Pair(0L, null), parseRange(""))
|
||||
// }
|
||||
//
|
||||
// @Test
|
||||
// fun getContentLength() {
|
||||
// var h = mapOf<String, List<String>>()
|
||||
// assertEquals(-1, getContentLength(h, task))
|
||||
// h = mapOf("Content-Length" to listOf("123"))
|
||||
// assertEquals(123, getContentLength(h, task))
|
||||
// h = mapOf("content-length" to listOf("123"))
|
||||
// assertEquals(123, getContentLength(h, task))
|
||||
// task = task.copyWith(headers = mapOf("Range" to "bytes=0-20"))
|
||||
// h = mapOf()
|
||||
// assertEquals(21, getContentLength(h, task))
|
||||
// task = task.copyWith(headers = mapOf("Known-Content-Length" to "456"))
|
||||
// assertEquals(456, getContentLength(h, task))
|
||||
// task = task.copyWith(headers = mapOf("Known-Content-Length" to "456", "Range" to "bytes=0-20"))
|
||||
// assertEquals(21, getContentLength(h, task))
|
||||
// h = mapOf("Content-Length" to listOf("123"))
|
||||
// assertEquals(123, getContentLength(h, task))
|
||||
// }
|
||||
//}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.bbflight.background_downloader
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ReRunDecisionTest {
|
||||
|
||||
@Test
|
||||
fun firstRunRuns() {
|
||||
assertEquals(ReRunDecision.run, reRunDecision(0, true, emptyList()))
|
||||
// also when its task left the tasks map, or another work exists
|
||||
assertEquals(ReRunDecision.run, reRunDecision(0, false, emptyList()))
|
||||
assertEquals(ReRunDecision.run, reRunDecision(0, true, listOf("other")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reRunOfUnfinishedTaskRuns() {
|
||||
assertEquals(ReRunDecision.run, reRunDecision(1, true, emptyList()))
|
||||
assertEquals(ReRunDecision.run, reRunDecision(3, true, emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reRunOfFinishedTaskIsSkipped() {
|
||||
assertEquals(ReRunDecision.skipFinished, reRunDecision(1, false, emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reRunWithAnotherUnfinishedWorkIsSkipped() {
|
||||
assertEquals(ReRunDecision.skipSuperseded, reRunDecision(1, true, listOf("other")))
|
||||
// the other work holds the slot, so superseded wins over finished
|
||||
assertEquals(ReRunDecision.skipSuperseded, reRunDecision(2, false, listOf("other")))
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.bbflight.background_downloader
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
@Serializable
|
||||
data class Holder(val dir: BaseDirectory)
|
||||
|
||||
|
||||
class SerializationTest {
|
||||
|
||||
@Test
|
||||
fun encodeEnum() {
|
||||
var enc = bdJson.encodeToString(Holder(BaseDirectory.applicationDocuments))
|
||||
assertEquals("{\"dir\":0}", enc)
|
||||
enc = bdJson.encodeToString(Holder(BaseDirectory.applicationLibrary))
|
||||
assertEquals("{\"dir\":3}", enc)
|
||||
val dec = bdJson.decodeFromString<Holder>(enc)
|
||||
assertEquals(BaseDirectory.applicationLibrary, dec.dir)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoreUnknownKeysTest() {
|
||||
val jsonWithUnknownKeys = "{\"dir\":0,\"unknownField\":123,\"anotherUnknown\":\"xyz\"}"
|
||||
val dec = bdJson.decodeFromString<Holder>(jsonWithUnknownKeys)
|
||||
assertEquals(BaseDirectory.applicationDocuments, dec.dir)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
#
|
||||
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html.
|
||||
# Run `pod lib lint background_downloader.podspec` to validate before publishing.
|
||||
#
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'background_downloader'
|
||||
s.version = '0.0.1'
|
||||
s.summary = 'A new Flutter plugin project.'
|
||||
s.description = <<-DESC
|
||||
A new Flutter plugin project.
|
||||
DESC
|
||||
s.homepage = 'http://example.com'
|
||||
s.license = { :file => '../LICENSE' }
|
||||
s.author = { 'Your Company' => 'email@example.com' }
|
||||
s.source = { :path => '.' }
|
||||
s.source_files = 'background_downloader/Sources/background_downloader/**/*.swift'
|
||||
s.dependency 'Flutter'
|
||||
s.platform = :ios, '14.0'
|
||||
|
||||
# Flutter.framework does not contain a i386 slice.
|
||||
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
|
||||
s.swift_version = '5.0'
|
||||
s.resource_bundles = {'background_downloader_privacy' => ['background_downloader/Sources/background_downloader/PrivacyInfo.xcprivacy']}
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
// swift-tools-version: 5.9
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import Foundation
|
||||
import PackageDescription
|
||||
|
||||
var swiftSettings: [SwiftSetting] = []
|
||||
if ProcessInfo.processInfo.environment["BYPASS_PERMISSION_NOTIFICATIONS"] == "1" {
|
||||
swiftSettings.append(.define("BYPASS_PERMISSION_NOTIFICATIONS"))
|
||||
}
|
||||
if ProcessInfo.processInfo.environment["BYPASS_PERMISSION_IOSADDTOPHOTOLIBRARY"] == "1" {
|
||||
swiftSettings.append(.define("BYPASS_PERMISSION_IOSADDTOPHOTOLIBRARY"))
|
||||
}
|
||||
if ProcessInfo.processInfo.environment["BYPASS_PERMISSION_IOSCHANGEPHOTOLIBRARY"] == "1" {
|
||||
swiftSettings.append(.define("BYPASS_PERMISSION_IOSCHANGEPHOTOLIBRARY"))
|
||||
}
|
||||
|
||||
let package = Package(
|
||||
name: "background_downloader",
|
||||
platforms: [
|
||||
.iOS("14.0")
|
||||
],
|
||||
products: [
|
||||
.library(name: "background-downloader", targets: ["background_downloader"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(name: "FlutterFramework", path: "../FlutterFramework")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "background_downloader",
|
||||
dependencies: [
|
||||
.product(name: "FlutterFramework", package: "FlutterFramework")
|
||||
],
|
||||
resources: [
|
||||
.process("PrivacyInfo.xcprivacy"),
|
||||
],
|
||||
swiftSettings: swiftSettings.isEmpty ? nil : swiftSettings
|
||||
)
|
||||
]
|
||||
)
|
||||
third_party/background_downloader/ios/background_downloader/Sources/background_downloader/Auth.swift
Vendored
+92
@@ -0,0 +1,92 @@
|
||||
//
|
||||
// Auth.swift
|
||||
//
|
||||
// Created by Bram on 11/8/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct Auth: Codable, Hashable {
|
||||
var accessToken: String?
|
||||
var accessHeaders: [String: String] = [:]
|
||||
var accessQueryParams: [String: String] = [:]
|
||||
var accessTokenExpiryTime: Int64?
|
||||
var refreshToken: String?
|
||||
var refreshHeaders: [String: String] = [:]
|
||||
var refreshQueryParams: [String: String] = [:]
|
||||
var refreshUrl: String?
|
||||
var onAuthRawHandle: Int64?
|
||||
|
||||
/**
|
||||
* Returns the headers specified for the access request, with the
|
||||
* template `{accessToken}` and `{refreshToken}` replaced.
|
||||
*/
|
||||
func getExpandedAccessHeaders() -> [String: String] {
|
||||
return expandMap(accessHeaders)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the query parameters specified for the access request, with the
|
||||
* template `{accessToken}` and `{refreshToken}` replaced.
|
||||
*/
|
||||
func getExpandedAccessQueryParams() -> [String: String] {
|
||||
return expandMap(accessQueryParams)
|
||||
}
|
||||
|
||||
/// Add/update the query parameters in this `url` with `queryParams`
|
||||
/// and return the new URI.
|
||||
func addOrUpdateQueryParams(
|
||||
url: String,
|
||||
queryParams: [String: String] = [:]
|
||||
) -> URL {
|
||||
guard let startUri = URL(string: url) else {
|
||||
// Handle invalid URL gracefully, e.g., return the original URL or throw an error
|
||||
return URL(string: url)!
|
||||
}
|
||||
guard !queryParams.isEmpty else { return startUri }
|
||||
|
||||
var components = URLComponents(url: startUri, resolvingAgainstBaseURL: false)!
|
||||
var updatedQueryParams = components.queryItems?.reduce(into: [String: String]()) { (result, item) in
|
||||
result[item.name] = item.value
|
||||
} ?? [:]
|
||||
|
||||
for (key, value) in queryParams {
|
||||
updatedQueryParams[key] = value
|
||||
}
|
||||
|
||||
components.queryItems = updatedQueryParams.map { URLQueryItem(name: $0.key, value: $0.value) }
|
||||
return components.url!
|
||||
}
|
||||
|
||||
/// Returns true if the `accessTokenExpiryTime` is after now plus
|
||||
/// the `bufferTime`, otherwise returns false.
|
||||
func isTokenExpired(bufferTime: Int64 = 10000) -> Bool {
|
||||
guard let expiry = accessTokenExpiryTime else { return false }
|
||||
let expiryTimeWithBuffer = Int64(Date().timeIntervalSince1970 * 1000) + bufferTime
|
||||
return expiryTimeWithBuffer > expiry
|
||||
}
|
||||
|
||||
/// Expands the [mapToExpand] by replacing {accessToken} and {refreshToken}
|
||||
///
|
||||
/// Returns the expanded map, without changing the original
|
||||
private func expandMap(_ mapToExpand: [String: String]) -> [String: String] {
|
||||
var newMap: [String: String] = [:]
|
||||
for (key, value) in mapToExpand {
|
||||
var newValue = value
|
||||
if let accessToken = accessToken {
|
||||
newValue = newValue.replacingOccurrences(of: "{accessToken}", with: accessToken)
|
||||
}
|
||||
if let refreshToken = refreshToken {
|
||||
newValue = newValue.replacingOccurrences(of: "{refreshToken}", with: refreshToken)
|
||||
}
|
||||
newMap[key] = newValue
|
||||
}
|
||||
return newMap
|
||||
}
|
||||
|
||||
/// True if this `Auth` object has an 'onAuth' callback that can be called to refresh the
|
||||
/// access token.
|
||||
func hasOnAuthCallback() -> Bool {
|
||||
return onAuthRawHandle != nil
|
||||
}
|
||||
}
|
||||
+1044
File diff suppressed because it is too large.
Load diff
+16
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// BackgroundDownloaderPlugin.swift
|
||||
// background_downloader
|
||||
//
|
||||
// Created by Bram on 1/9/25.
|
||||
//
|
||||
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
@objc(BackgroundDownloaderPlugin) // Keep the Objective-C name for compatibility
|
||||
public class BackgroundDownloaderPlugin: NSObject, FlutterPlugin {
|
||||
public static func register(with registrar: FlutterPluginRegistrar) {
|
||||
BDPlugin.register(with: registrar)
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
//
|
||||
// Callbacks.swift
|
||||
// background_downloader
|
||||
//
|
||||
// Created by Bram on 10/26/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Flutter
|
||||
import os.log
|
||||
|
||||
import UIKit
|
||||
|
||||
/// Helper function to get the callback channel with retry
|
||||
private func getCallbackChannel() async -> FlutterMethodChannel? {
|
||||
var retries = 0
|
||||
while BDPlugin.callbackChannel == nil && retries < 5 {
|
||||
try? await _Concurrency.Task.sleep(nanoseconds: 100_000_000) // 0.1 second
|
||||
retries += 1
|
||||
}
|
||||
return BDPlugin.callbackChannel
|
||||
}
|
||||
|
||||
/// Invoke a callback in Dart (via the method channel) and return the returned Task value or nil
|
||||
private func invokeCallback(withMethod methodName: String, forTask task: Task) async -> Any? {
|
||||
guard let callbackChannel = await getCallbackChannel() else {
|
||||
os_log("Could not invoke %{public}@ for taskId %@: callbackChannel not set", log: log, type: .error, methodName, task.taskId)
|
||||
return nil
|
||||
}
|
||||
return await withCheckedContinuation { continuation in
|
||||
os_log("Invoking %{public}@ for taskId %@", log: log, type: .info, methodName, task.taskId)
|
||||
callbackChannel.invokeMethod(methodName, arguments: jsonStringFor(task: task), result: { result in
|
||||
guard let jsonString = result as? String else {
|
||||
if let error = result as? FlutterError {
|
||||
os_log("Error invoking %{public}@: %@", log: log, type: .error, methodName, error.message ?? "nil")
|
||||
}
|
||||
continuation.resume(returning: nil)
|
||||
return
|
||||
}
|
||||
if methodName == "beforeTaskStartCallback" {
|
||||
continuation.resume(returning: try? JSONDecoder().decode(TaskStatusUpdate.self, from: (jsonString).data(using: .utf8)!))
|
||||
}
|
||||
else {
|
||||
continuation.resume(returning: taskFrom(jsonString: jsonString))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Invoke the onTaskStartCallback in Dart (via the method channel) and return the returned Task value or nil
|
||||
func invokeBeforeTaskStartCallback(task: Task) async -> TaskStatusUpdate? {
|
||||
return await invokeCallback(withMethod: "beforeTaskStartCallback", forTask: task) as? TaskStatusUpdate
|
||||
}
|
||||
|
||||
/// Invoke the onTaskStartCallback in Dart (via the method channel) and return the returned Task value or nil
|
||||
func invokeOnTaskStartCallback(task: Task) async -> Task? {
|
||||
return await invokeCallback(withMethod: "onTaskStartCallback", forTask: task) as? Task
|
||||
}
|
||||
|
||||
/// Invoke the onAuthCallback in Dart (via the method channel) and return the returned Task value or nil
|
||||
func invokeOnAuthCallback(task: Task) async -> Task? {
|
||||
return await invokeCallback(withMethod: "onAuthCallback", forTask: task) as? Task
|
||||
}
|
||||
|
||||
/// Invoke the onTaskFinishedCallback in Dart (via the method channel) and return true if successful
|
||||
func invokeOnTaskFinishedCallback(taskStatusUpdate: TaskStatusUpdate) async -> Bool {
|
||||
guard let callbackChannel = await getCallbackChannel() else {
|
||||
os_log("Could not invoke onTaskFinishedCallback: callbackChannel not set", log: log, type: .error)
|
||||
return false
|
||||
}
|
||||
return await withCheckedContinuation { continuation in
|
||||
callbackChannel.invokeMethod("onTaskFinishedCallback", arguments: jsonStringFor(taskStatusUpdate: taskStatusUpdate), result: { result in
|
||||
if let error = result as? FlutterError {
|
||||
os_log("Error invoking onTaskFinishedCallback: %@", log: log, type: .error, error.message ?? "nil")
|
||||
continuation.resume(returning: false)
|
||||
} else {
|
||||
continuation.resume(returning: true)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
//
|
||||
// Helpers.swift
|
||||
// background_downloader
|
||||
//
|
||||
// Created by Bram on 12/31/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
import os.log
|
||||
|
||||
extension URL {
|
||||
/// Uses .appending for iOS 16 and up, and .appendingPathComponent
|
||||
/// for earlier versions
|
||||
func appendingPath(_ component: String, isDirectory: Bool = false) -> URL {
|
||||
if #available(iOS 16.0, *) {
|
||||
return appending(path: component, directoryHint: isDirectory ? .isDirectory : .notDirectory)
|
||||
} else {
|
||||
return appendingPathComponent(component, isDirectory: isDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
/// Excludes URL from backup
|
||||
mutating func setCloudBackup(exclude: Bool) throws {
|
||||
var resource = URLResourceValues()
|
||||
resource.isExcludedFromBackup = exclude
|
||||
try self.setResourceValues(resource)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the task's URL if it can be parsed, otherwise null
|
||||
func validateUrl(_ task: Task) -> URL? {
|
||||
let url: URL?
|
||||
// encodingInvalidCharacters is only available when compiling with Xcode 15, which uses Swift version 5.9
|
||||
#if swift(>=5.9)
|
||||
if #available(iOS 17.0, *) {
|
||||
url = URL(string: task.url, encodingInvalidCharacters: false)
|
||||
} else {
|
||||
url = URL(string: task.url)
|
||||
}
|
||||
#else
|
||||
url = URL(string: task.url)
|
||||
#endif
|
||||
return url
|
||||
}
|
||||
|
||||
/// Converts the [map] to a [String:String] map with lowercased keys
|
||||
func lowerCasedStringStringMap(_ map: [AnyHashable: Any]?) -> [String: String]? {
|
||||
if map == nil {return nil}
|
||||
var result: [String: String] = [:]
|
||||
for (key, value) in map! {
|
||||
if let stringKey = key as? String, let stringValue = value as? String {
|
||||
result[stringKey.lowercased()] = stringValue
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Create a temp file that contains a subset of the [fileURL] based on [start] and [contentLength]
|
||||
///
|
||||
/// Returns the URL of the temp file, or nil if there was a problem (problem is logged)
|
||||
func createTempFileWithRange(from fileURL: URL, start: UInt64, contentLength: UInt64) -> URL? {
|
||||
let fileManager = FileManager.default
|
||||
let tempDir = fileManager.temporaryDirectory
|
||||
let tempFileURL = tempDir.appendingPathComponent(UUID().uuidString) // Create a unique temporary file
|
||||
|
||||
// Create the temporary file
|
||||
fileManager.createFile(atPath: tempFileURL.path, contents: nil, attributes: nil)
|
||||
guard let inputHandle = try? FileHandle(forReadingFrom: fileURL),
|
||||
let outputHandle = try? FileHandle(forWritingTo: tempFileURL) else {
|
||||
os_log("Cannot open file handles for partial upload temporary file creation", log: log, type: .error)
|
||||
return nil
|
||||
}
|
||||
defer {
|
||||
try? inputHandle.close()
|
||||
try? outputHandle.close()
|
||||
}
|
||||
let bufferSize = 1024 * 1024 // 1MB chunks
|
||||
var remainingBytes = contentLength
|
||||
do {
|
||||
// Seek to the start position
|
||||
try inputHandle.seek(toOffset: start)
|
||||
while remainingBytes > 0 {
|
||||
let bytesToRead = Int(min(UInt64(bufferSize), remainingBytes))
|
||||
guard let data = try inputHandle.read(upToCount: bytesToRead), !data.isEmpty else {
|
||||
break // EOF
|
||||
}
|
||||
try outputHandle.write(contentsOf: data)
|
||||
remainingBytes -= UInt64(data.count)
|
||||
}
|
||||
} catch {
|
||||
os_log("Cannot create temporary file for partial upload: %@", log: log, type: .error,
|
||||
error.localizedDescription)
|
||||
return nil
|
||||
}
|
||||
return tempFileURL
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
Returns mimetype of a filename based on its extension, or application/octet-stream
|
||||
*/
|
||||
func getMimeType(fromFilename filename: String) -> String {
|
||||
// Extract the file extension from the filename
|
||||
let fileExtension = (filename as NSString).pathExtension
|
||||
if let type = UTType(filenameExtension: fileExtension) {
|
||||
return type.preferredMIMEType ?? "application/octet-stream"
|
||||
}
|
||||
// Default MIME type if unable to determine
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
/// Returns the root view controller of the app, or nil if it cannot be found
|
||||
/// Handles both the old AppDelegate (window) and the new SceneDelegate (connectedScenes) ways of getting the window
|
||||
func getRootViewController() -> UIViewController? {
|
||||
if #available(iOS 13, *) {
|
||||
let window = UIApplication.shared.connectedScenes
|
||||
.filter { $0.activationState == .foregroundActive }
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.flatMap { $0.windows }
|
||||
.first { $0.isKeyWindow }
|
||||
if let root = window?.rootViewController {
|
||||
return root
|
||||
}
|
||||
}
|
||||
return UIApplication.shared.keyWindow?.rootViewController ?? UIApplication.shared.delegate?.window??.rootViewController
|
||||
}
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
//
|
||||
// HoldingQueue.swift
|
||||
// background_downloader
|
||||
//
|
||||
// Created by Bram on 3/16/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
/**
|
||||
* Queue that holds [EnqueueItem] items before they are actually enqueued as a URLSessionTask
|
||||
*
|
||||
* Configure [maxConcurrent], [maxConcurrentByHost] and [maxConcurrentByGroup] to limit which items
|
||||
* can be enqueued simultaneously.
|
||||
*
|
||||
* Call:
|
||||
* [add] to add an [EnqueueItem]
|
||||
* [taskFinished] for all tasks that finish, so we may start a new one
|
||||
* [cancelAllTasks] to empty the queue (sends status updates)
|
||||
* [cancelTasksWithIds] to remove specific tasks (sends status updates)
|
||||
* [allTasks] for a list of [Task] matching a group
|
||||
* [taskForId] to get the task for a specific taskId
|
||||
*/
|
||||
class HoldingQueue {
|
||||
var maxConcurrent: Int = 1000000
|
||||
var maxConcurrentByHost: Int = 1000000
|
||||
var maxConcurrentByGroup: Int = 1000000
|
||||
var enqueuedTaskIds = [String]()
|
||||
|
||||
private var concurrent = 0
|
||||
private var concurrentByHost = [String: Int]()
|
||||
private var concurrentByGroup = [String: Int]()
|
||||
|
||||
private var queue = [EnqueueItem]() // Using an array as a substitute for a priority queue
|
||||
|
||||
let stateLock = AsyncLock()
|
||||
private var job: DispatchWorkItem? = nil // for advanceQueue in future
|
||||
|
||||
/**
|
||||
* Add [EnqueueItem] [item] to the queue and advance the queue if possible
|
||||
*/
|
||||
func add(item: EnqueueItem) async {
|
||||
await stateLock.lock()
|
||||
queue.append(item)
|
||||
queue.sort()
|
||||
enqueuedTaskIds.append(item.task.taskId)
|
||||
await registerEnqueue(task: item.task,
|
||||
notificationConfigJsonString: item.notificationConfigJsonString,
|
||||
success: true) // for accurate groupnotification count
|
||||
advanceQueue()
|
||||
await stateLock.unlock()
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals to the holdingQueue that a [task] has finished
|
||||
*
|
||||
* Adjusts the state variables and advances the queue
|
||||
*
|
||||
* To prevent deadlock when called when the lock is already obtained, set [reEntry]
|
||||
*/
|
||||
func taskFinished(_ task: Task, reEntry: Bool = false) async {
|
||||
if !reEntry {
|
||||
await stateLock.lock()
|
||||
}
|
||||
let host = getHost(task)
|
||||
concurrent -= 1
|
||||
concurrentByHost[host]? -= 1
|
||||
concurrentByGroup[task.group]? -= 1
|
||||
if let index = enqueuedTaskIds.firstIndex(of: task.taskId) {
|
||||
enqueuedTaskIds.remove(at: index)
|
||||
}
|
||||
advanceQueue()
|
||||
if (!reEntry) {
|
||||
await stateLock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all [EnqueueItem] where their taskId is in [taskIds], sends a
|
||||
* [TaskStatus.canceled] update and returns a list of
|
||||
* taskIds that were cancelled this way.
|
||||
*
|
||||
* Because this is used in combination with the UrlSessions tasks, use of this method
|
||||
* requires the caller to acquire the [stateLock]
|
||||
*/
|
||||
func cancelTasksWithIds(_ taskIds: [String]) -> [String] {
|
||||
let toRemove = queue.filter( { taskIds.contains($0.task.taskId) } )
|
||||
toRemove.forEach { item in
|
||||
processStatusUpdate(task: item.task, status: .canceled)
|
||||
if let configString = item.notificationConfigJsonString,
|
||||
let config = notificationConfigFrom(jsonString: configString) {
|
||||
_Concurrency.Task {
|
||||
await updateGroupNotification(task: item.task, notificationType: .canceled, notificationConfig: config)
|
||||
}
|
||||
}
|
||||
os_log("Canceled task with id %@", log: log, type: .info, item.task.taskId)
|
||||
}
|
||||
queue.removeAll(where: { taskIds.contains($0.task.taskId)})
|
||||
return toRemove.map { $0.task.taskId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel (delete) all [EnqueueItem] matching [group], send a
|
||||
* [TaskStatus.canceled] for each and return the number of items cancelled
|
||||
*
|
||||
* Because this is used in combination with the UrlSessions tasks, use of this method
|
||||
* requires the caller to acquire the [stateLock]
|
||||
*/
|
||||
func cancelAllTasks(group: String) -> Int {
|
||||
let taskIds = queue.filter({ $0.task.group == group }).map { $0.task.taskId }
|
||||
return cancelTasksWithIds(taskIds).count
|
||||
}
|
||||
|
||||
/**
|
||||
* Return task matching [taskId], or null
|
||||
*
|
||||
* Because this is used in combination with the UrlSessions tasks, use of this method
|
||||
* requires the caller to acquire the [stateLock]
|
||||
*/
|
||||
func taskForId(_ taskId: String) -> Task? {
|
||||
let tasks = queue.filter( { $0.task.taskId == taskId } ).map { $0.task }
|
||||
if !tasks.isEmpty {
|
||||
return tasks.first
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/**
|
||||
* Return list of [Task] for this [group]. If [group] is nil al tasks are returned
|
||||
*
|
||||
* Because this is used in combination with the UrlSessions tasks, use of this method
|
||||
* requires the caller to acquire the [stateLock]
|
||||
*/
|
||||
func allTasks(group: String?) -> [Task] {
|
||||
return queue.filter( { group == nil || $0.task.group == group } ).map { $0.task }
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the queue by signalling the queue processing coroutine
|
||||
*
|
||||
* Also restarts a timer that will advance the queue in 10 seconds, in case
|
||||
* it dries up
|
||||
*/
|
||||
private func advanceQueue() {
|
||||
DispatchQueue.global().async {
|
||||
_Concurrency.Task {
|
||||
await self.processQueue()
|
||||
}
|
||||
}
|
||||
advanceQueueInFuture()
|
||||
}
|
||||
|
||||
private func advanceQueueInFuture() {
|
||||
job?.cancel()
|
||||
job = DispatchWorkItem {
|
||||
_Concurrency.Task {
|
||||
await self.calculateState()
|
||||
self.advanceQueue()
|
||||
}
|
||||
}
|
||||
guard let job = job else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 10, execute: job)
|
||||
|
||||
}
|
||||
|
||||
/// Processes items in the queue, if possible
|
||||
private func processQueue() async {
|
||||
await stateLock.lock()
|
||||
var itemsToEnqueue = [EnqueueItem]()
|
||||
while concurrent < maxConcurrent && !queue.isEmpty {
|
||||
var mustWait = [EnqueueItem]()
|
||||
var foundItem: EnqueueItem? = nil
|
||||
while !queue.isEmpty {
|
||||
let item = queue.removeFirst()
|
||||
let host = getHost(item.task)
|
||||
if (concurrentByHost[host] ?? 0) < maxConcurrentByHost &&
|
||||
(concurrentByGroup[item.task.group] ?? 0) < maxConcurrentByGroup {
|
||||
concurrent += 1
|
||||
concurrentByHost[host, default: 0] += 1
|
||||
concurrentByGroup[item.task.group, default: 0] += 1
|
||||
foundItem = item
|
||||
break
|
||||
} else {
|
||||
mustWait.append(item)
|
||||
}
|
||||
}
|
||||
queue.append(contentsOf: mustWait)
|
||||
queue.sort()
|
||||
if let item = foundItem {
|
||||
itemsToEnqueue.append(item)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
await stateLock.unlock()
|
||||
|
||||
// Enqueue items outside the stateLock to avoid blocking during network HEAD requests
|
||||
for item in itemsToEnqueue {
|
||||
await item.enqueue()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the [concurrent], [concurrentByHost] and [concurrentByGroup] values
|
||||
*
|
||||
* This is expensive, so is only done initially and when the [advanceQueueInFuture] timer
|
||||
* fires
|
||||
*/
|
||||
private func calculateState() async {
|
||||
await stateLock.lock()
|
||||
UrlSessionDelegate.createUrlSession()
|
||||
guard let urlSessionTasks = await UrlSessionDelegate.urlSession?.allTasks else {
|
||||
await stateLock.unlock()
|
||||
return
|
||||
}
|
||||
let tasks: [Task] = urlSessionTasks.filter({ $0.state != .completed }).compactMap({ getTaskFrom(urlSessionTask: $0)})
|
||||
concurrent = tasks.count
|
||||
concurrentByHost.removeAll()
|
||||
concurrentByGroup.removeAll()
|
||||
for task in tasks {
|
||||
let host = getHost(task)
|
||||
concurrentByHost[host, default: 0] += 1
|
||||
concurrentByGroup[task.group, default: 0] += 1
|
||||
}
|
||||
await stateLock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Holds data related to enqueueing a task
|
||||
*
|
||||
* Used in the context of changing the RequireWiFi setting (where tasks need to be re-enqueued)
|
||||
* and in the context of the [HoldingQueue]
|
||||
*/
|
||||
struct EnqueueItem : Comparable {
|
||||
let task: Task
|
||||
let notificationConfigJsonString: String?
|
||||
let resumeDataAsBase64String: String
|
||||
let created = Date()
|
||||
|
||||
// Comparable implementation to sort based on task priority and creation time
|
||||
static func < (lhs: EnqueueItem, rhs: EnqueueItem) -> Bool {
|
||||
return lhs.task.priority == rhs.task.priority ? lhs.task.creationTime < rhs.task.creationTime : lhs.task.priority < rhs.task.priority
|
||||
}
|
||||
|
||||
static func == (lhs: EnqueueItem, rhs: EnqueueItem) -> Bool {
|
||||
return lhs.task.priority == rhs.task.priority && lhs.task.creationTime == rhs.task.creationTime
|
||||
}
|
||||
|
||||
func enqueue() async {
|
||||
let success = await BDPlugin.instance.doEnqueue(taskJsonString: jsonStringFor(task: task) ?? "", notificationConfigJsonString: notificationConfigJsonString, resumeDataAsBase64String: resumeDataAsBase64String)
|
||||
if !success {
|
||||
os_log("Delayed or retried enqueue failed for taskId %@", log: log, type: .info, task.taskId)
|
||||
processStatusUpdate(task: task, status: .failed, taskException: TaskException(type: .general, description: "Delayed or retried enqueue failed"))
|
||||
await BDPlugin.holdingQueue?.taskFinished(task, reEntry: true)
|
||||
// register the failure with the notification service (for accurate groupnotification count)
|
||||
await registerEnqueue(task: task, notificationConfigJsonString: notificationConfigJsonString, success: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Traditional lock for asyn/await environment
|
||||
actor AsyncLock {
|
||||
private var isLocked = false
|
||||
|
||||
func lock() async {
|
||||
while isLocked {
|
||||
await _Concurrency.Task.yield()
|
||||
}
|
||||
isLocked = true
|
||||
}
|
||||
|
||||
func unlock() async {
|
||||
isLocked = false
|
||||
}
|
||||
}
|
||||
+483
@@ -0,0 +1,483 @@
|
||||
//
|
||||
// Notifications.swift
|
||||
// background_downloader
|
||||
//
|
||||
// Created on 3/19/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import os.log
|
||||
|
||||
|
||||
/// NotificationContents
|
||||
struct NotificationContents : Codable {
|
||||
let title: String
|
||||
let body: String
|
||||
|
||||
init(json: [String:Any]) {
|
||||
title = json["title"] as? String ?? ""
|
||||
body = json["body"] as? String ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationConfig : Codable {
|
||||
let running: NotificationContents?
|
||||
let complete: NotificationContents?
|
||||
let error: NotificationContents?
|
||||
let paused: NotificationContents?
|
||||
let canceled: NotificationContents?
|
||||
let progressBar: Bool
|
||||
let tapOpensFile: Bool
|
||||
let groupNotificationId: String
|
||||
}
|
||||
|
||||
enum NotificationType : Int {
|
||||
case running,
|
||||
complete,
|
||||
error,
|
||||
paused,
|
||||
canceled
|
||||
}
|
||||
|
||||
/// Data and methods related to a notification for a group of tasks
|
||||
actor GroupNotification {
|
||||
private static var registry: [String : GroupNotification] = [:]
|
||||
private static let registryLock = NSLock()
|
||||
|
||||
/// Atomically gets (or creates and registers) the notification for this
|
||||
/// group. Serialised via a lock: this is called concurrently from
|
||||
/// [updateGroupNotification] for every task status update, and
|
||||
/// unsynchronised access to the shared dictionary corrupts it
|
||||
/// (EXC_BAD_ACCESS).
|
||||
static func groupNotification(named name: String, notificationConfig: NotificationConfig) -> GroupNotification {
|
||||
registryLock.lock()
|
||||
defer { registryLock.unlock() }
|
||||
if let existing = registry[name] {
|
||||
return existing
|
||||
}
|
||||
let created = GroupNotification(name: name, notificationConfig: notificationConfig)
|
||||
registry[name] = created
|
||||
return created
|
||||
}
|
||||
|
||||
/// Removes the notification for this group from the registry
|
||||
static func remove(named name: String) {
|
||||
registryLock.lock()
|
||||
defer { registryLock.unlock() }
|
||||
registry.removeValue(forKey: name)
|
||||
}
|
||||
|
||||
let name: String
|
||||
let notificationConfig: NotificationConfig
|
||||
|
||||
private var notifications: [Task : NotificationType]
|
||||
|
||||
init(name: String, notificationConfig: NotificationConfig) {
|
||||
self.name = name
|
||||
self.notificationConfig = notificationConfig
|
||||
self.notifications = [:]
|
||||
}
|
||||
|
||||
/// NotificationId derived from group name
|
||||
var notificationId: String {
|
||||
get {
|
||||
return "groupNotification:\(name)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Total number of notifications in this group
|
||||
var numTotal: Int {
|
||||
get {
|
||||
return notifications.count
|
||||
}
|
||||
}
|
||||
|
||||
/// Progress expressed as [numFinished]/[numTotal], except
|
||||
/// return 2.0 if numTotal is 0, to suggest that progress
|
||||
/// is undetermined
|
||||
var progress: Double {
|
||||
get {
|
||||
if numTotal == 0 {
|
||||
return 2.0
|
||||
} else {
|
||||
return Double(numFinished) / Double(numTotal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of "finished" notifications in this group.
|
||||
///
|
||||
/// A "finished" notification is one that is not .running,
|
||||
/// so includes .complete, .error, .paused
|
||||
///
|
||||
var numFinished: Int {
|
||||
get {
|
||||
return notifications.filter { (_, v) in v != NotificationType.running }.count
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of "failed" notifications in this group.
|
||||
///
|
||||
/// A "failed" notification is one of type .error
|
||||
///
|
||||
var numFailed: Int {
|
||||
get {
|
||||
return notifications.filter { (_, v) in v == NotificationType.error }.count
|
||||
}
|
||||
}
|
||||
|
||||
/// True if all tasks finished, regardless of outcome
|
||||
var isFinished: Bool {
|
||||
get {
|
||||
return numFinished == numTotal
|
||||
}
|
||||
}
|
||||
|
||||
///
|
||||
/// Return true if this group has an error
|
||||
///
|
||||
var hasError: Bool {
|
||||
get {
|
||||
return numFailed > 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a Set of running tasks in this notificationGroup
|
||||
var runningTasks: Set<Task> {
|
||||
get {
|
||||
return Set(notifications.filter { (_, notificationType) in
|
||||
notificationType == NotificationType.running
|
||||
}.keys)
|
||||
}
|
||||
}
|
||||
|
||||
/// Int representing this group's state. If this number
|
||||
/// does not change, the group state did not change.
|
||||
///
|
||||
/// State is determined by the number of finished notifications
|
||||
/// and the number of total notifications
|
||||
private var groupState: Int {
|
||||
get {
|
||||
return 1000 * numTotal + numFinished
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a [task] and [notificationType] to this group,
|
||||
/// and return True if this led to change in [groupState]
|
||||
func update(task: Task, notificationType: NotificationType) -> Bool {
|
||||
guard notifications[task] != notificationType else {
|
||||
return false
|
||||
}
|
||||
let priorState = groupState
|
||||
notifications[task] = notificationType
|
||||
return priorState != groupState
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
enum NotificationCategory : String, CaseIterable {
|
||||
case runningWithPause = "running_with_pause";
|
||||
case runningWithoutPause = "running_without_pause";
|
||||
case paused = "paused"
|
||||
case complete = "complete"
|
||||
case error = "error"
|
||||
case canceled = "canceled"
|
||||
}
|
||||
|
||||
/// List of all category identifiers
|
||||
let ourCategories = NotificationCategory.allCases.map { $0.rawValue }
|
||||
|
||||
/// Update notification for this [task], based on [notificationType] and [notificationConfig]
|
||||
func updateNotification(task: Task, notificationType: NotificationType, notificationConfig: NotificationConfig?) {
|
||||
_Concurrency.Task { @MainActor in // run using concurrency on main thread
|
||||
if (await getPermissionStatus(for: .notifications)) != .granted {
|
||||
return
|
||||
}
|
||||
if !BDPlugin.haveregisteredNotificationCategories {
|
||||
registerNotificationCategories()
|
||||
BDPlugin.haveregisteredNotificationCategories = true
|
||||
}
|
||||
let notificationCenter = UNUserNotificationCenter.current()
|
||||
if notificationConfig?.groupNotificationId.isEmpty ?? true {
|
||||
// regular notification
|
||||
var notification: NotificationContents?
|
||||
switch notificationType {
|
||||
case .running:
|
||||
notification = notificationConfig?.running
|
||||
case .complete:
|
||||
notification = notificationConfig?.complete
|
||||
case .error:
|
||||
notification = notificationConfig?.error
|
||||
case .paused:
|
||||
notification = notificationConfig?.paused
|
||||
case .canceled:
|
||||
notification = notificationConfig?.canceled
|
||||
}
|
||||
if notification == nil {
|
||||
// remove a previous notification for this task (e.g. 'running'), as on Android
|
||||
notificationCenter.removeDeliveredNotifications(withIdentifiers: [task.taskId])
|
||||
return
|
||||
}
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = await replaceTokens(input: notification!.title, task: task)
|
||||
content.body = await replaceTokens(input: notification!.body, task: task)
|
||||
content.userInfo = [
|
||||
"task": jsonStringFor(task: task) ?? "",
|
||||
"notificationConfig": jsonStringFor(notificationConfig: notificationConfig!) ?? "",
|
||||
"notificationType": notificationType.rawValue
|
||||
]
|
||||
addNotificationActions(task: task, notificationType: notificationType, content: content, notificationConfig: notificationConfig!)
|
||||
let request = UNNotificationRequest(identifier: task.taskId,
|
||||
content: content, trigger: nil)
|
||||
do {
|
||||
try await notificationCenter.add(request)
|
||||
} catch {
|
||||
os_log("Notification error %@", log: log, type: .info, error.localizedDescription)
|
||||
}
|
||||
} else {
|
||||
// group notification
|
||||
await updateGroupNotification(task: task, notificationType: notificationType, notificationConfig: notificationConfig!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update notification for this [taskWorker] in group
|
||||
* [notificationGroupName] and type [notificationType].
|
||||
*
|
||||
* A group notification aggregates the state of all tasks in a group and
|
||||
* presents a notification based on that value
|
||||
*/
|
||||
func updateGroupNotification(
|
||||
task: Task,
|
||||
notificationType: NotificationType,
|
||||
notificationConfig: NotificationConfig
|
||||
) async {
|
||||
let groupNotificationId = notificationConfig.groupNotificationId
|
||||
let groupNotification = GroupNotification.groupNotification(named: groupNotificationId, notificationConfig: notificationConfig)
|
||||
let stateChange = await groupNotification.update(task: task, notificationType: notificationType)
|
||||
if stateChange {
|
||||
// need to update the group notification
|
||||
let notificationCenter = UNUserNotificationCenter.current()
|
||||
let hasError = await groupNotification.hasError
|
||||
let isFinished = await groupNotification.isFinished
|
||||
var notification: NotificationContents?
|
||||
if isFinished {
|
||||
if hasError {
|
||||
notification = groupNotification.notificationConfig.error
|
||||
} else {
|
||||
notification = groupNotification.notificationConfig.complete
|
||||
}
|
||||
} else {
|
||||
notification = groupNotification.notificationConfig.running
|
||||
}
|
||||
let notificationId = await groupNotification.notificationId
|
||||
if let notification = notification {
|
||||
// need to show a notification
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = await replaceTokens(input: notification.title, task: task, progress: await groupNotification.progress, notificationGroup: groupNotification)
|
||||
content.body = await replaceTokens(input: notification.body, task: task, progress: await groupNotification.progress, notificationGroup: groupNotification)
|
||||
// check if the notification title or body have changed relative to what may
|
||||
// already be delivered, to avoid flashing notifications without change
|
||||
var shouldPost = isFinished
|
||||
if !shouldPost {
|
||||
let existingNotifications = await notificationCenter.deliveredNotifications()
|
||||
let previousNotification = existingNotifications.filter {
|
||||
$0.request.identifier == notificationId
|
||||
}
|
||||
shouldPost = previousNotification.isEmpty || previousNotification.first?.request.content.title != content.title || previousNotification.first?.request.content.body != content.body
|
||||
}
|
||||
if shouldPost
|
||||
{
|
||||
content.categoryIdentifier = !isFinished
|
||||
? NotificationCategory.runningWithoutPause.rawValue
|
||||
: (hasError ? NotificationCategory.error.rawValue : NotificationCategory.complete.rawValue)
|
||||
let request = UNNotificationRequest(identifier: notificationId,
|
||||
content: content, trigger: nil)
|
||||
do {
|
||||
try await notificationCenter.add(request)
|
||||
} catch {
|
||||
os_log("Notification error %@", log: log, type: .info, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// remove notification
|
||||
notificationCenter.removeDeliveredNotifications(withIdentifiers: [notificationId])
|
||||
}
|
||||
if isFinished {
|
||||
// remove only if not re-activated within 5 seconds
|
||||
try? await _Concurrency.Task.sleep(nanoseconds: 5_000_000_000)
|
||||
if await groupNotification.isFinished {
|
||||
GroupNotification.remove(named: groupNotificationId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Register that [task] was enqueued, with [success] or failure
|
||||
///
|
||||
/// This is only relevant for tasks that are part of a group notification, so that the
|
||||
/// 'numTotal' count is based on enqueued tasks, not on running tasks (which may be limited
|
||||
/// by holdingQueue or OS restrictions).
|
||||
/// [notificationConfigJsonString] is only used to detect if this task has a group notification
|
||||
func registerEnqueue(task: Task, notificationConfigJsonString: String?, success: Bool) async {
|
||||
guard let notificationConfigJsonString = notificationConfigJsonString,
|
||||
let notificationConfig = notificationConfigFrom(jsonString: notificationConfigJsonString),
|
||||
!notificationConfig.groupNotificationId.isEmpty
|
||||
else { return }
|
||||
await updateGroupNotification(task: task, notificationType: success ? .running : .error, notificationConfig: notificationConfig)
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Add action buttons to the notification
|
||||
///
|
||||
/// Which button(s) depends on the [notificationType]. Action buttons are defined when defining the notification categories
|
||||
func addNotificationActions(task: Task, notificationType: NotificationType, content: UNMutableNotificationContent, notificationConfig: NotificationConfig) {
|
||||
BDPlugin.propertyLock.withLock( {
|
||||
switch notificationType {
|
||||
case .running:
|
||||
content.categoryIdentifier = BDPlugin.taskIdsThatCanResume.contains(task.taskId) && notificationConfig.paused != nil ? NotificationCategory.runningWithPause.rawValue : NotificationCategory.runningWithoutPause.rawValue
|
||||
case .paused:
|
||||
content.categoryIdentifier = NotificationCategory.paused.rawValue
|
||||
case .complete:
|
||||
content.categoryIdentifier = NotificationCategory.complete.rawValue
|
||||
case .error:
|
||||
content.categoryIdentifier = NotificationCategory.error.rawValue
|
||||
case .canceled:
|
||||
content.categoryIdentifier = NotificationCategory.canceled.rawValue
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the notificationType related to this [status]
|
||||
func notificationTypeForTaskStatus(status: TaskStatus) -> NotificationType {
|
||||
switch status {
|
||||
case .enqueued, .running: return NotificationType.running
|
||||
case .complete: return NotificationType.complete
|
||||
case .paused: return NotificationType.paused
|
||||
case .canceled: return NotificationType.canceled
|
||||
default: return NotificationType.error
|
||||
}
|
||||
}
|
||||
|
||||
let displayNameRegEx = try! NSRegularExpression(pattern: "\\{displayName\\}", options: NSRegularExpression.Options.caseInsensitive)
|
||||
let fileNameRegEx = try! NSRegularExpression(pattern: "\\{filename\\}", options: NSRegularExpression.Options.caseInsensitive)
|
||||
let metaDataRegEx = try! NSRegularExpression(pattern: "\\{metadata\\}", options: NSRegularExpression.Options.caseInsensitive)
|
||||
let progressRegEx = try! NSRegularExpression(pattern: "\\{progress\\}", options: NSRegularExpression.Options.caseInsensitive)
|
||||
let networkSpeedRegEx = try! NSRegularExpression(pattern: "\\{networkSpeed\\}", options: NSRegularExpression.Options.caseInsensitive)
|
||||
let timeRemainingRegEx = try! NSRegularExpression(pattern: "\\{timeRemaining\\}", options: NSRegularExpression.Options.caseInsensitive)
|
||||
let numFinishedRegEx = try! NSRegularExpression(pattern: "\\{numFinished\\}", options: NSRegularExpression.Options.caseInsensitive)
|
||||
let numFailedRegEx = try! NSRegularExpression(pattern: "\\{numFailed\\}", options: NSRegularExpression.Options.caseInsensitive)
|
||||
let numTotalRegEx = try! NSRegularExpression(pattern: "\\{numTotal\\}", options: NSRegularExpression.Options.caseInsensitive)
|
||||
|
||||
/// Replace special tokens {filename} and {metadata} with their respective values
|
||||
func replaceTokens(input: String, task: Task, progress: Double? = nil, notificationGroup: GroupNotification? = nil) async -> String {
|
||||
let inputString = NSMutableString()
|
||||
inputString.append(input)
|
||||
|
||||
let unpacked = unpack(packedString: task.filename)
|
||||
let safeFilename = unpacked.filename ?? task.filename
|
||||
|
||||
displayNameRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: task.displayName)
|
||||
fileNameRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: safeFilename)
|
||||
metaDataRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: task.metaData)
|
||||
if (progress == nil) {
|
||||
progressRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: "")}
|
||||
else {
|
||||
progressRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: "\(Int(progress! * 100))%")
|
||||
}
|
||||
networkSpeedRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: "-- MB/s")
|
||||
timeRemainingRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: "--:--")
|
||||
if (notificationGroup != nil) {
|
||||
let numFinished = await notificationGroup!.numFinished
|
||||
let numFailed = await notificationGroup!.numFailed
|
||||
let numTotal = await notificationGroup!.numTotal
|
||||
numFinishedRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: "\(numFinished)")
|
||||
numFailedRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: "\(numFailed)")
|
||||
numTotalRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: "\(numTotal)")
|
||||
}
|
||||
return inputString as String
|
||||
}
|
||||
|
||||
/// Registers notification categories and actions for the different notification types
|
||||
func registerNotificationCategories() {
|
||||
// get values from shared preferences
|
||||
let defaults = UserDefaults.standard
|
||||
let localize = defaults.dictionary(forKey: BDPlugin.keyConfigLocalize)
|
||||
|
||||
// define the actions
|
||||
let cancelAction = UNNotificationAction(identifier: "cancel_action",
|
||||
title: localize?["Cancel"] as? String ?? "Cancel",
|
||||
options: [])
|
||||
let cancelInactiveAction = UNNotificationAction(identifier: "cancel_inactive_action",
|
||||
title: localize?["Cancel"] as? String ?? "Cancel",
|
||||
options: [])
|
||||
let pauseAction = UNNotificationAction(identifier: "pause_action",
|
||||
title: localize?["Pause"] as? String ?? "Pause",
|
||||
options: [])
|
||||
let resumeAction = UNNotificationAction(identifier: "resume_action",
|
||||
title: localize?["Resume"] as? String ?? "Resume",
|
||||
options: [])
|
||||
// Define the notification categories using these actions
|
||||
let runningWithPauseCategory =
|
||||
UNNotificationCategory(identifier: NotificationCategory.runningWithPause.rawValue,
|
||||
actions: [cancelAction, pauseAction],
|
||||
intentIdentifiers: [],
|
||||
hiddenPreviewsBodyPlaceholder: "",
|
||||
options: .customDismissAction)
|
||||
let runningWithoutPauseCategory =
|
||||
UNNotificationCategory(identifier: NotificationCategory.runningWithoutPause.rawValue,
|
||||
actions: [cancelAction],
|
||||
intentIdentifiers: [],
|
||||
hiddenPreviewsBodyPlaceholder: "",
|
||||
options: .customDismissAction)
|
||||
let pausedCategory =
|
||||
UNNotificationCategory(identifier: NotificationCategory.paused.rawValue,
|
||||
actions: [cancelInactiveAction, resumeAction],
|
||||
intentIdentifiers: [],
|
||||
hiddenPreviewsBodyPlaceholder: "",
|
||||
options: .customDismissAction)
|
||||
let completeCategory =
|
||||
UNNotificationCategory(identifier: NotificationCategory.complete.rawValue,
|
||||
actions: [],
|
||||
intentIdentifiers: [],
|
||||
hiddenPreviewsBodyPlaceholder: "",
|
||||
options: .customDismissAction)
|
||||
let errorCategory =
|
||||
UNNotificationCategory(identifier: NotificationCategory.error.rawValue,
|
||||
actions: [],
|
||||
intentIdentifiers: [],
|
||||
hiddenPreviewsBodyPlaceholder: "",
|
||||
options: .customDismissAction)
|
||||
let canceledCategory =
|
||||
UNNotificationCategory(identifier: NotificationCategory.canceled.rawValue,
|
||||
actions: [],
|
||||
intentIdentifiers: [],
|
||||
hiddenPreviewsBodyPlaceholder: "",
|
||||
options: .customDismissAction)
|
||||
// Register the notification type.
|
||||
let notificationCenter = UNUserNotificationCenter.current()
|
||||
notificationCenter.setNotificationCategories([runningWithPauseCategory, runningWithoutPauseCategory, pausedCategory, completeCategory, errorCategory, canceledCategory])
|
||||
}
|
||||
|
||||
/// Returns a JSON string for this NotificationConfig, or nil
|
||||
func jsonStringFor(notificationConfig: NotificationConfig) -> String? {
|
||||
let jsonEncoder = JSONEncoder()
|
||||
guard let jsonResultData = try? jsonEncoder.encode(notificationConfig)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return String(data: jsonResultData, encoding: .utf8)
|
||||
|
||||
}
|
||||
|
||||
/// Returns a NotificationConfig from the supplied jsonString, or nil
|
||||
func notificationConfigFrom(jsonString: String) -> NotificationConfig? {
|
||||
let decoder = JSONDecoder()
|
||||
let notificationConfig: NotificationConfig? = try? decoder.decode(NotificationConfig.self, from: (jsonString).data(using: .utf8)!)
|
||||
return notificationConfig
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
//
|
||||
// OpenFile.swift
|
||||
// background_downloader
|
||||
//
|
||||
// Created on 4/27/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MobileCoreServices
|
||||
import UIKit
|
||||
#if canImport(UniformTypeIdentifiers)
|
||||
import UniformTypeIdentifiers
|
||||
#endif
|
||||
|
||||
///
|
||||
func doOpenFile(filePath: String, mimeType: String?) -> Bool {
|
||||
let fileUrl = URL(fileURLWithPath: filePath)
|
||||
let documentInteractionController = UIDocumentInteractionController(url: fileUrl)
|
||||
let delegate = DocumentInteractionControllerDelegate()
|
||||
documentInteractionController.delegate = delegate
|
||||
if (mimeType != nil) {
|
||||
if #available(iOS 14.0, *) {
|
||||
if let type = UTType(mimeType: mimeType!) {
|
||||
documentInteractionController.uti = type.identifier
|
||||
}
|
||||
} else {
|
||||
if let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, mimeType! as NSString, nil)?.takeRetainedValue()
|
||||
{
|
||||
documentInteractionController.uti = uti as String
|
||||
}
|
||||
}
|
||||
}
|
||||
guard let view = getRootViewController()?.view
|
||||
else {
|
||||
return false
|
||||
}
|
||||
if !documentInteractionController.presentPreview(animated: true) {
|
||||
documentInteractionController.presentOpenInMenu(from: view.bounds, in: view, animated: true)
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
class DocumentInteractionControllerDelegate: NSObject, UIDocumentInteractionControllerDelegate {
|
||||
|
||||
func documentInteractionControllerViewControllerForPreview(_ controller: UIDocumentInteractionController) -> UIViewController {
|
||||
return getRootViewController()!
|
||||
}
|
||||
}
|
||||
+463
@@ -0,0 +1,463 @@
|
||||
//
|
||||
// ParallelDownloader.swift
|
||||
// background_downloader
|
||||
//
|
||||
// Created on 9/18/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
let chunkGroup = "chunk"
|
||||
|
||||
func scheduleParallelDownload(task: Task, taskDescription: String, baseRequest: URLRequest, resumeData: String) async -> Bool
|
||||
{
|
||||
let isResume = !resumeData.isEmpty
|
||||
let parallelDownload = ParallelDownloader(task: task)
|
||||
if !isResume {
|
||||
return await withCheckedContinuation { continuation in
|
||||
let dataTask = URLSession.shared.dataTask(with: baseRequest) { (data, response, error) in
|
||||
if let httpResponse = response as? HTTPURLResponse, error == nil {
|
||||
if httpResponse.statusCode == 404 {
|
||||
os_log("URL not found for taskId %@", log: log, type: .info, task.taskId)
|
||||
}
|
||||
else if !parallelDownload.start(responseStatusCode: httpResponse.statusCode, contentLengthFromHeader: Int64(httpResponse.value(forHTTPHeaderField: "Content-Length") ?? "-1") ?? -1, responseHeaders: httpResponse.allHeaderFields ) {
|
||||
os_log("Cannot chunk or enqueue download", log: log, type: .info)
|
||||
} else {
|
||||
if BDPlugin.holdingQueue?.enqueuedTaskIds.contains(task.taskId) != true {
|
||||
// only send .enqueued if we didn't already do that when
|
||||
// enqueueing with the holdingQueue
|
||||
processStatusUpdate(task: task, status: TaskStatus.enqueued)
|
||||
}
|
||||
continuation.resume(returning: true)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
os_log("Error making HEAD request for taskId %@", log: log, type: .info, task.taskId)
|
||||
}
|
||||
continuation.resume(returning: false)
|
||||
}
|
||||
dataTask.priority = 1 - Float(task.priority) / 10
|
||||
dataTask.resume()
|
||||
}
|
||||
} else {
|
||||
// resume
|
||||
return parallelDownload.resume(resumeData: resumeData)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/ Manages a parallel download
|
||||
/
|
||||
/ 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 ParallelDownloader sends 'enqueue' messages back to the NativeDownloader to
|
||||
/ start each chunk-task, just like any other download task.
|
||||
/ Messages with group 'chunk' are intercepted in the NativeDownloader,
|
||||
/ and the update is sent to the ParallelDownloader.
|
||||
/ In the ParallelDownloader, 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
|
||||
*/
|
||||
public class ParallelDownloader: NSObject {
|
||||
// downloads is the list of active parallel downloads, used to route child
|
||||
// status and progress updates
|
||||
static var downloads: [String : ParallelDownloader] = [:] // keyed by parentTask.taskId
|
||||
var parentTask: Task
|
||||
var chunks: [Chunk] = []
|
||||
var parallelDownloadContentLength: Int64 = 0
|
||||
var lastTaskStatus: TaskStatus = .enqueued
|
||||
var lastProgress: Double = 0
|
||||
var nextProgressUpdateTime = Date()
|
||||
var taskException: TaskException? = nil
|
||||
var responseBody: String? = nil
|
||||
var responseHeaders: [AnyHashable : Any]? = nil
|
||||
var initialResponseStatusCode: Int? = nil
|
||||
|
||||
/// Create a new ParallelDownloader
|
||||
init(task:Task) {
|
||||
self.parentTask = task
|
||||
}
|
||||
|
||||
/// Start the parallel download by creating and enqueueing chunks based on
|
||||
/// the content length and header fields
|
||||
///
|
||||
/// Returns false if start was unsuccessful
|
||||
public func start(responseStatusCode: Int, contentLengthFromHeader: Int64, responseHeaders: [AnyHashable: Any]) -> Bool {
|
||||
if !(200...206).contains(responseStatusCode) {
|
||||
os_log("TaskId %@ returned response code %d", log: log, type: .info, parentTask.taskId, responseStatusCode)
|
||||
return false
|
||||
}
|
||||
initialResponseStatusCode = responseStatusCode // may change due to chunk errors
|
||||
self.responseHeaders = responseHeaders
|
||||
// get suggested filename if needed
|
||||
if parentTask.filename == "?" {
|
||||
let newTask = taskWithSuggestedFilenameFromResponseHeaders(task: parentTask, responseHeaders: responseHeaders)
|
||||
if newTask.filename != parentTask.filename {
|
||||
// store for future replacement, and replace now
|
||||
storeModifiedTask(task: newTask)
|
||||
parentTask = newTask
|
||||
}
|
||||
}
|
||||
parallelDownloadContentLength = contentLengthFromHeader > 0
|
||||
? contentLengthFromHeader
|
||||
: getContentLength(responseHeaders: responseHeaders, task: self.parentTask)
|
||||
if parallelDownloadContentLength <= 0 {
|
||||
os_log("Server does not provide content length - cannot chunk download. If you know the length, set Range or Known-Content-Length header", log: log, type: .info)
|
||||
return false
|
||||
}
|
||||
if responseHeaders.first(where: { entry in (entry.key as! String).lowercased() == "accept-ranges" && entry.value as! String == "bytes"}) == nil {
|
||||
os_log("Server does not accept ranges - cannot chunk download", log: log, type: .info)
|
||||
return false
|
||||
}
|
||||
extractContentType(responseHeaders: responseHeaders, task: self.parentTask)
|
||||
ParallelDownloader.downloads[parentTask.taskId] = self
|
||||
chunks = createChunks(task: parentTask, contentLength: parallelDownloadContentLength)
|
||||
let success = !chunks.isEmpty && enqueueChunkTasks()
|
||||
if !success {
|
||||
ParallelDownloader.downloads.removeValue(forKey: parentTask.taskId)
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
/// resume: reconstruct [chunks] and wait for all chunk tasks to complete.
|
||||
/// The Dart side will resume each chunk task, so we just wait for the
|
||||
/// completer to complete
|
||||
func resume(resumeData: String) -> Bool {
|
||||
ParallelDownloader.downloads[parentTask.taskId] = self
|
||||
let decoder = JSONDecoder()
|
||||
guard
|
||||
let chunkList = try? decoder.decode([Chunk].self, from: resumeData.data(using: .utf8)!)
|
||||
else {
|
||||
os_log("Could not decode resumeData for taskid %@", log: log, type: .info, parentTask.taskId)
|
||||
return false
|
||||
}
|
||||
chunks = chunkList
|
||||
parallelDownloadContentLength = chunks.reduce(0, { partialResult, chunk in
|
||||
partialResult + chunk.toByte - chunk.fromByte + 1
|
||||
})
|
||||
lastTaskStatus = .paused
|
||||
return true
|
||||
}
|
||||
|
||||
/// 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]
|
||||
func createChunks(task: Task, contentLength: Int64) -> [Chunk] {
|
||||
let numChunks = task.urls!.count * task.chunks!
|
||||
if contentLength <= 0 {
|
||||
os_log("Server does not provide content length - cannot chunk download", log: log, type: .info)
|
||||
return []
|
||||
}
|
||||
parallelDownloadContentLength = contentLength
|
||||
let chunkSize = (contentLength / Int64(numChunks)) + 1
|
||||
var chunksList: [Chunk] = []
|
||||
for i in 0..<numChunks {
|
||||
chunksList.append(Chunk(
|
||||
parentTask: task,
|
||||
url: task.urls![i % task.urls!.count],
|
||||
filename: "\(Int.random(in: 1..<1 << 32))",
|
||||
fromByte: Int64(i) * chunkSize,
|
||||
toByte: min(Int64(i) * chunkSize + chunkSize - 1, contentLength - 1)))
|
||||
}
|
||||
return chunksList
|
||||
}
|
||||
|
||||
/// Enqueues all chunk tasks and returns true if successful
|
||||
///
|
||||
/// Enqueue request is posted to Dart side
|
||||
func enqueueChunkTasks() -> Bool {
|
||||
let jsonEncoder = JSONEncoder()
|
||||
for chunk in chunks {
|
||||
guard let taskAsJsonData = try? jsonEncoder.encode(chunk.task)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
if !postOnBackgroundChannel(method: "enqueueChild", task: parentTask, arg: String(data: taskAsJsonData, encoding: .utf8) as Any) {
|
||||
os_log("Could not enqueue child task for chunk", log: log, type: .info)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Process incoming [status] update for a chunk with [chunkTaskId]
|
||||
///
|
||||
/// If status is failure, may include [taskException] and [responseBody]
|
||||
func chunkStatusUpdate(chunkTaskId: String, status: TaskStatus, taskException: TaskException?, responseBody: String?) {
|
||||
// Confirm chunk is part of this parent task
|
||||
guard let chunk = chunks.first(where: { $0.task.taskId == chunkTaskId }) else { return }
|
||||
// first check for fail -> retry
|
||||
if status == .failed && chunk.task.retriesRemaining > 0 {
|
||||
chunk.task.retriesRemaining -= 1
|
||||
let waitTimeSeconds = 2 << min(chunk.task.retries - chunk.task.retriesRemaining - 1, 8)
|
||||
os_log("Chunk with taskId %@ failed, waiting %d seconds to retry; %d retries remaining", log: log, type: .info, chunk.task.taskId, waitTimeSeconds, chunk.task.retriesRemaining)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Double(waitTimeSeconds)) {
|
||||
if !postOnBackgroundChannel(
|
||||
method: "enqueueChild",
|
||||
task: self.parentTask,
|
||||
arg: jsonStringFor(task: chunk.task)!
|
||||
) {
|
||||
self.chunkStatusUpdate(chunkTaskId: chunkTaskId, status: .failed, taskException: taskException, responseBody: responseBody)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// no retry
|
||||
let newStatusUpdate = updateChunkStatus(chunk: chunk, status: status)
|
||||
if let newStatusUpdate = newStatusUpdate {
|
||||
switch newStatusUpdate {
|
||||
case .running:
|
||||
processStatusUpdate(task: parentTask, status: .running)
|
||||
case .complete:
|
||||
let stitchResult = stitchChunks()
|
||||
if stitchResult == TaskStatus.complete {
|
||||
os_log("Finished task with id %@", log: log, type: .info, parentTask.taskId)
|
||||
}
|
||||
finishTask(status: stitchResult)
|
||||
case .failed:
|
||||
self.taskException = taskException
|
||||
cancelAllChunkTasks()
|
||||
finishTask(status: .failed)
|
||||
case .notFound:
|
||||
self.responseBody = responseBody
|
||||
cancelAllChunkTasks()
|
||||
finishTask(status: .notFound)
|
||||
default:
|
||||
// ignore all other status updates
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Process incoming [progress] update for a chunk with [chunkTaskId].
|
||||
///
|
||||
/// Recalculates overall task progress (based on the average of the chunk
|
||||
/// task progress) and sends an updatre to the Dart isde and updates the
|
||||
/// notification at the appropriate interval
|
||||
func chunkProgressUpdate(chunkTaskId: String, progress: Double) {
|
||||
guard let chunk = chunks.first(where: { $0.task.taskId == chunkTaskId }) else {
|
||||
return // chunk is not part of this parent task
|
||||
}
|
||||
if progress > 0 && progress < 1 {
|
||||
let parentProgress = updateChunkProgress(chunk: chunk, progress: progress)
|
||||
let totalBytesDone = Int64(parentProgress * Double(parallelDownloadContentLength))
|
||||
updateProgress(task: parentTask, totalBytesExpected: parallelDownloadContentLength, totalBytesDone: totalBytesDone)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 NativeDownloader, which intercepts
|
||||
/// status updates for the chunkGroup
|
||||
private func updateChunkStatus(chunk: Chunk, status: TaskStatus) -> TaskStatus? {
|
||||
chunk.status = status
|
||||
let parentStatus = parentTaskStatus()
|
||||
if let parentStatus = parentStatus, parentStatus != lastTaskStatus {
|
||||
lastTaskStatus = parentStatus
|
||||
return parentStatus
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Returns the [TaskStatus] for the parent of this chunk, as derived from
|
||||
/// the 'sum' of the child tasks, or nil if undetermined
|
||||
///
|
||||
/// The updates are received from the NativeDownloader, which intercepts
|
||||
/// status updates for the chunkGroup
|
||||
private func parentTaskStatus() -> TaskStatus? {
|
||||
if chunks.first(where: { $0.status == .failed }) != nil {
|
||||
return .failed
|
||||
}
|
||||
if chunks.first(where: { $0.status == .notFound }) != nil {
|
||||
return .notFound
|
||||
}
|
||||
if (chunks.reduce(into: 0) { previousValue, chunk in
|
||||
if chunk.status == .running {
|
||||
previousValue += 1
|
||||
}
|
||||
}) == 1 {
|
||||
return .running // if exactly one chunk task is running
|
||||
}
|
||||
if chunks.allSatisfy({ $0.status == .complete }) {
|
||||
return .complete
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Updates the chunk's progress and returns the average progress
|
||||
///
|
||||
/// Returns the [progress] for the parent of this chunk, as derived from
|
||||
/// its children by averaging
|
||||
private func updateChunkProgress(chunk: Chunk, progress: Double) -> Double {
|
||||
chunk.progress = progress
|
||||
return chunks.reduce(into: 0.0) { previousValue, chunk in
|
||||
previousValue += chunk.progress
|
||||
} / Double(chunks.count)
|
||||
}
|
||||
|
||||
/// Stitch all chunks together into one file, per the [parentTask]
|
||||
private func stitchChunks() -> TaskStatus {
|
||||
do {
|
||||
let fileManager = FileManager.default
|
||||
let outputFilePath = getFilePath(for: parentTask)!
|
||||
let outputFile = URL(fileURLWithPath: outputFilePath)
|
||||
if fileManager.fileExists(atPath: outputFilePath) {
|
||||
try? fileManager.removeItem(at: outputFile)
|
||||
}
|
||||
fileManager.createFile(atPath: outputFilePath, contents: nil)
|
||||
let outputFileHandle = try FileHandle(forWritingTo: outputFile)
|
||||
defer {
|
||||
outputFileHandle.closeFile()
|
||||
for chunk in chunks {
|
||||
let inputFilePath = getFilePath(for: chunk.task)!
|
||||
let inputFileURL = URL(fileURLWithPath: inputFilePath)
|
||||
if fileManager.fileExists(atPath: inputFilePath) {
|
||||
try? FileManager.default.removeItem(at: inputFileURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
for chunk in chunks.sorted(by: { $0.fromByte < $1.fromByte }) {
|
||||
let filePath = getFilePath(for: chunk.task)!
|
||||
let fileHandle = try FileHandle(forReadingFrom: URL(fileURLWithPath: filePath))
|
||||
defer {
|
||||
fileHandle.closeFile()
|
||||
}
|
||||
while true {
|
||||
let data = fileHandle.readData(ofLength: 2 << 13)
|
||||
if data.isEmpty {
|
||||
break
|
||||
}
|
||||
outputFileHandle.write(data)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
os_log("Error stitching chunks: %@", log: log, type: .info, error.localizedDescription)
|
||||
taskException = TaskException(type: .fileSystem, description: "Error stitching chunks: \(error.localizedDescription)")
|
||||
return .failed
|
||||
}
|
||||
return .complete
|
||||
}
|
||||
|
||||
/// Cancel this task
|
||||
///
|
||||
/// Cancels all chunk tasks and completes the task with [TaskStatus.canceled]
|
||||
func cancelTask() {
|
||||
cancelAllChunkTasks()
|
||||
finishTask(status: .canceled)
|
||||
}
|
||||
|
||||
/// Pause this task
|
||||
///
|
||||
/// Pauses all chunk tasks
|
||||
func pauseTask() async -> Bool {
|
||||
let encoder = JSONEncoder()
|
||||
guard
|
||||
let chunkTasksData = try? encoder.encode(chunks.map({ chunk in
|
||||
chunk.task })),
|
||||
let chunksData = try? encoder.encode(chunks)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
if !postOnBackgroundChannel(method: "pauseTasks", task: parentTask, arg: String(data: chunkTasksData, encoding: .utf8)!)
|
||||
{
|
||||
os_log("Could not pause chunk tasks for taskId %@", log: log, type: .info, parentTask.taskId)
|
||||
return false
|
||||
}
|
||||
if !postOnBackgroundChannel(method: "resumeData", task: parentTask, arg: String(data: chunksData, encoding: .utf8)!) {
|
||||
os_log("Could not post resume data for taskId %@", log: log, type: .info, parentTask.taskId)
|
||||
// because we already paused the
|
||||
cancelAllChunkTasks()
|
||||
processStatusUpdate(task: parentTask, status: .failed)
|
||||
return false
|
||||
}
|
||||
processStatusUpdate(task: parentTask, status: .paused)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Cancel the tasks associated with each chunk
|
||||
///
|
||||
/// Accomplished by sending list of taskIds to cancel to the NativeDownloader
|
||||
private func cancelAllChunkTasks() {
|
||||
let encoder = JSONEncoder()
|
||||
guard let data = try? encoder.encode(chunks.map({ $0.task.taskId })) else {
|
||||
os_log("Could not encode chunk ids", log: log, type: .error)
|
||||
return
|
||||
}
|
||||
if !postOnBackgroundChannel(method: "cancelTasksWithId", task: parentTask, arg: String(data: data, encoding: .utf8)!) {
|
||||
os_log("Could not cancel chunk tasks related to taskId %@", log: log, type: .info, parentTask.taskId)
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish the [ParallelDownloadTask] by posting a statusUpdate and clearning up
|
||||
private func finishTask(status: TaskStatus) {
|
||||
let taskId = parentTask.taskId
|
||||
let (mimeType, charSet) = BDPlugin.propertyLock.withLock({
|
||||
(BDPlugin.mimeTypes[taskId], BDPlugin.charSets[taskId])
|
||||
})
|
||||
var responseStatusCode: Int? = nil
|
||||
switch status {
|
||||
case .complete:
|
||||
responseStatusCode = initialResponseStatusCode
|
||||
|
||||
case .notFound:
|
||||
responseStatusCode = 404
|
||||
|
||||
default:
|
||||
responseStatusCode = nil
|
||||
}
|
||||
processStatusUpdate(task: parentTask, status: status, taskException: taskException, responseBody: responseBody, responseHeaders: responseHeaders, responseStatusCode: responseStatusCode, mimeType: mimeType, charSet: charSet)
|
||||
BDPlugin.propertyLock.withLock({
|
||||
BDPlugin.mimeTypes.removeValue(forKey: taskId)
|
||||
BDPlugin.charSets.removeValue(forKey: taskId)
|
||||
BDPlugin.tasksWithModifications.removeValue(forKey: taskId)
|
||||
ParallelDownloader.downloads.removeValue(forKey: taskId)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents one chunk of a parallel download
|
||||
///
|
||||
/// Initialized to a specific range of the parent task.
|
||||
/// Holds state (status and progress) of the chunk
|
||||
public class Chunk: NSObject, Codable {
|
||||
let parentTaskId: String
|
||||
let url: String
|
||||
let filename: String
|
||||
let fromByte: Int64
|
||||
let toByte: Int64
|
||||
var task: Task
|
||||
var status: TaskStatus = TaskStatus.enqueued
|
||||
var progress = 0.0
|
||||
|
||||
|
||||
init(parentTask: Task, url: String, filename: String, fromByte: Int64, toByte: Int64) {
|
||||
self.parentTaskId = parentTask.taskId
|
||||
self.url = url
|
||||
self.filename = filename
|
||||
self.fromByte = fromByte
|
||||
self.toByte = toByte
|
||||
var headers = parentTask.headers
|
||||
headers["Range"] = "bytes=\(fromByte)-\(toByte)"
|
||||
let jsonEncoder = JSONEncoder()
|
||||
let data = try? jsonEncoder.encode(["parentTaskId": self.parentTaskId, "from": String(fromByte), "to": String(toByte)])
|
||||
let metaData = data != nil ? String(data: data!, encoding: .utf8) ?? "" : ""
|
||||
self.task = Task(url: url, filename: filename, headers: headers, baseDirectory: BaseDirectory.temporary.rawValue, group: chunkGroup, updates: Chunk.updatesBasedOnParent(parentTask), retries: parentTask.retries, retriesRemaining: parentTask.retries, allowPause: parentTask.allowPause, priority: parentTask.priority, metaData: metaData, taskType: "DownloadTask")
|
||||
}
|
||||
|
||||
/// Returns [Updates] enum rawValue based on its parent
|
||||
static func updatesBasedOnParent(_ parentTask: Task) -> Int {
|
||||
return parentTask.updates == Updates.none.rawValue || parentTask.updates == Updates.statusChange.rawValue ? Updates.statusChange.rawValue : Updates.statusChangeAndProgressUpdates.rawValue
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
//
|
||||
// Permissions.swift
|
||||
// background_downloader
|
||||
//
|
||||
// Created by Bram on 11/30/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Photos
|
||||
import UserNotifications
|
||||
import os.log
|
||||
|
||||
public enum PermissionType: Int {
|
||||
case notifications,
|
||||
androidSharedStorage,
|
||||
iosAddToPhotoLibrary,
|
||||
iosChangePhotoLibrary
|
||||
}
|
||||
|
||||
public enum PermissionStatus: Int {
|
||||
case undetermined,
|
||||
denied,
|
||||
granted,
|
||||
partial,
|
||||
requestError }
|
||||
|
||||
/// Get current permission status for this [request]
|
||||
///
|
||||
/// Unknown permissions resturn .granted
|
||||
public func getPermissionStatus(for permissionType: PermissionType) async -> PermissionStatus {
|
||||
if permissionType == .notifications {
|
||||
#if BYPASS_PERMISSION_NOTIFICATIONS
|
||||
return .denied
|
||||
#else
|
||||
let center = UNUserNotificationCenter.current()
|
||||
let status = (await center.notificationSettings()).authorizationStatus
|
||||
switch status {
|
||||
case .authorized:
|
||||
return .granted
|
||||
|
||||
case .denied:
|
||||
return .denied
|
||||
|
||||
case .notDetermined:
|
||||
return .undetermined
|
||||
|
||||
default:
|
||||
return .partial
|
||||
}
|
||||
#endif
|
||||
}
|
||||
if (permissionType == .iosAddToPhotoLibrary || permissionType == .iosChangePhotoLibrary) {
|
||||
let status: PHAuthorizationStatus
|
||||
if permissionType == .iosAddToPhotoLibrary {
|
||||
#if BYPASS_PERMISSION_IOSADDTOPHOTOLIBRARY
|
||||
status = .denied
|
||||
#else
|
||||
if #available(iOS 14, *) {
|
||||
status = PHPhotoLibrary.authorizationStatus(for: .addOnly)
|
||||
} else {
|
||||
status = PHPhotoLibrary.authorizationStatus()
|
||||
}
|
||||
#endif
|
||||
} else { // readwrite
|
||||
#if BYPASS_PERMISSION_IOSCHANGEPHOTOLIBRARY
|
||||
status = .denied
|
||||
#else
|
||||
if #available(iOS 14, *) {
|
||||
status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
||||
} else {
|
||||
status = PHPhotoLibrary.authorizationStatus()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
switch status {
|
||||
case .authorized:
|
||||
return .granted
|
||||
|
||||
case .denied:
|
||||
return .denied
|
||||
|
||||
case .notDetermined:
|
||||
return .undetermined
|
||||
|
||||
default:
|
||||
return .partial
|
||||
}
|
||||
}
|
||||
return .granted // default return if irrelevant permission for iOS
|
||||
}
|
||||
|
||||
/// Request permission from user and return the [PermissionResult]
|
||||
///
|
||||
/// Unknown permissions are granted
|
||||
public func requestPermission(for permissionType: PermissionType) async -> PermissionStatus {
|
||||
if permissionType == .notifications {
|
||||
#if BYPASS_PERMISSION_NOTIFICATIONS
|
||||
return .denied
|
||||
#else
|
||||
let center = UNUserNotificationCenter.current()
|
||||
guard let granted = try? await center.requestAuthorization(options: [.alert]) else {
|
||||
return .requestError
|
||||
}
|
||||
return granted ? .granted : .denied
|
||||
#endif
|
||||
}
|
||||
if (permissionType == .iosAddToPhotoLibrary || permissionType == .iosChangePhotoLibrary) {
|
||||
let status: PHAuthorizationStatus
|
||||
if permissionType == .iosAddToPhotoLibrary {
|
||||
#if BYPASS_PERMISSION_IOSADDTOPHOTOLIBRARY
|
||||
status = .denied
|
||||
#else
|
||||
if #available(iOS 14, *) {
|
||||
status = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
|
||||
} else {
|
||||
status = await withCheckedContinuation { continuation in
|
||||
PHPhotoLibrary.requestAuthorization {status in
|
||||
continuation.resume(returning: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
} else { // readwrite
|
||||
#if BYPASS_PERMISSION_IOSCHANGEPHOTOLIBRARY
|
||||
status = .denied
|
||||
#else
|
||||
if #available(iOS 14, *) {
|
||||
status = await PHPhotoLibrary.requestAuthorization(for: .readWrite)
|
||||
} else {
|
||||
status = await withCheckedContinuation { continuation in
|
||||
PHPhotoLibrary.requestAuthorization {status in
|
||||
continuation.resume(returning: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
switch status {
|
||||
case .authorized:
|
||||
return .granted
|
||||
|
||||
case .denied:
|
||||
return .denied
|
||||
|
||||
case .notDetermined:
|
||||
return .undetermined
|
||||
|
||||
default:
|
||||
return .partial
|
||||
}
|
||||
}
|
||||
return .granted
|
||||
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypePhotosorVideos</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>CA92.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
//
|
||||
// SharedStorage.swift
|
||||
// background_downloader
|
||||
//
|
||||
// Created on 3/26/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import Photos
|
||||
import Combine
|
||||
|
||||
public enum SharedStorage: Int {
|
||||
case downloads,
|
||||
images,
|
||||
video,
|
||||
audio,
|
||||
files,
|
||||
external
|
||||
}
|
||||
|
||||
/// Check if photos permissions have been granted
|
||||
///
|
||||
/// if [addOnly] is true, checks .addOnly permission, otherwise checks
|
||||
/// full .readWrite permission.
|
||||
///
|
||||
/// Returns true if granted
|
||||
|
||||
/// .addOnly requires the "NSPhotoLibraryAddUsageDescription" to be set in Info.plist
|
||||
/// .readWrite requires the "NSPhotoLibraryUsageDescription" to be set in Info.plist
|
||||
/// Prior to iOS 14, use NSPhotoLibraryUsageDescription
|
||||
public func photosAccessAuthorized(addOnly: Bool) async -> Bool {
|
||||
let permissionType: PermissionType = addOnly ? .iosAddToPhotoLibrary : .iosChangePhotoLibrary
|
||||
var authStatus = await getPermissionStatus(for: permissionType)
|
||||
if (authStatus == .granted) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Move file at [filePath] to shared storage [destination] with optional [directory]
|
||||
public func moveToSharedStorage(filePathOrUriString: String, destination: SharedStorage, directory: String, asUriString: Bool) async -> String? {
|
||||
let fileUrl = URL(string: filePathOrUriString)
|
||||
let filePath = fileUrl?.scheme == "file" ? fileUrl!.path : filePathOrUriString
|
||||
guard FileManager().fileExists(atPath: filePath)
|
||||
else {
|
||||
os_log("Cannot move to shared storage: file %@ does not exist", log: log, type: .error, filePathOrUriString)
|
||||
return nil
|
||||
}
|
||||
switch destination {
|
||||
case .images, .video:
|
||||
return await moveToPhotoLibrary(filePath: filePath, destination: destination)
|
||||
default:
|
||||
return moveToFakeSharedStorage(filePath: filePath, destination: destination, directory: directory, asUriString: asUriString)
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the file at [filePath] to the photolibrary [destination] and removes the original
|
||||
///
|
||||
/// The returned path is the 'localId'
|
||||
private func moveToPhotoLibrary(filePath: String, destination: SharedStorage) async -> String? {
|
||||
var localId: String?
|
||||
guard await photosAccessAuthorized(addOnly: true) else {
|
||||
os_log("Cannot move to shared storage: permission to add to photos library denied by user", log: log, type: .info)
|
||||
return nil
|
||||
}
|
||||
#if BYPASS_PERMISSION_IOSADDTOPHOTOLIBRARY
|
||||
return nil
|
||||
#else
|
||||
guard let fileURL = URL(string: filePath) else {
|
||||
os_log("filePath invalid: %@", log: log, type: .info, filePath)
|
||||
return nil
|
||||
}
|
||||
let result = await withCheckedContinuation { continuation in
|
||||
PHPhotoLibrary.shared().performChanges({
|
||||
let assetChangeRequest = destination == .video ? PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: fileURL) :
|
||||
PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: fileURL)
|
||||
localId = assetChangeRequest?.placeholderForCreatedAsset?.localIdentifier
|
||||
})
|
||||
{ (success, error: Error?) in
|
||||
guard success, let unwrappedId = localId else {
|
||||
if let unwrappedError = error {
|
||||
os_log("Could not save to Photos Library: %@", log: log, type: .info, String(describing: unwrappedError))
|
||||
}
|
||||
continuation.resume(returning: "")
|
||||
return
|
||||
}
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
continuation.resume(returning: unwrappedId)
|
||||
}
|
||||
}
|
||||
return result.isEmpty ? nil : result
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Returns the path to the file at [filePath] in shared storage [destination] subdir [directory], or null
|
||||
public func pathInSharedStorage(filePath: String, destination: SharedStorage, directory: String, asUriString: Bool) async -> String? {
|
||||
if destination == .images || destination == .video {
|
||||
return await pathInPhotoLibrary(localId: filePath, destination: destination)
|
||||
}
|
||||
// regular 'faked' sharedStorage
|
||||
guard let directory = try? directoryForSharedStorage(destination: destination, directory: directory) else {
|
||||
os_log("Cannot determine path in shared storage: no permission for directory %@", log: log, type: .info, directory)
|
||||
return nil
|
||||
}
|
||||
let destUrl = directory.appendingPath((filePath as NSString).lastPathComponent)
|
||||
return asUriString ? destUrl.absoluteString : destUrl.path
|
||||
}
|
||||
|
||||
/// Returns the file path associated with the [localId] in the Photos library
|
||||
///
|
||||
/// This requires .readWrite PhotoLibrary access, and therefore the filepath is NOT returned
|
||||
/// when moving a photo or video to the PhotoLibrary using [moveToSharedStorage].
|
||||
/// Instead, the local identifier is returned, and if the full file path is required, this identifier needs to
|
||||
/// be passed to the [pathInSharedStorage] call
|
||||
///
|
||||
/// Returns the path to the photo/video asset, or nil if unsuccessful
|
||||
public func pathInPhotoLibrary(localId: String, destination: SharedStorage) async -> String? {
|
||||
guard await photosAccessAuthorized(addOnly: false) else {
|
||||
os_log("Cannot get path in shared storage: permission to access photos library denied by user", log: log, type: .info)
|
||||
return nil
|
||||
}
|
||||
#if BYPASS_PERMISSION_IOSCHANGEPHOTOLIBRARY
|
||||
return nil
|
||||
#else
|
||||
let assetResult = PHAsset.fetchAssets(withLocalIdentifiers: [localId], options: nil)
|
||||
guard let asset = assetResult.firstObject else {
|
||||
os_log("Photos asset not found", log: log, type: .info)
|
||||
return nil
|
||||
}
|
||||
let result = await withCheckedContinuation { continuation in
|
||||
asset.requestContentEditingInput(with: nil, completionHandler: { input, _ in
|
||||
if (destination == .video) {
|
||||
if let urlAsset = input?.audiovisualAsset as? AVURLAsset {
|
||||
continuation.resume(returning: urlAsset.url.path)
|
||||
} else {
|
||||
continuation.resume(returning: "")
|
||||
}
|
||||
} else {
|
||||
continuation.resume(returning: input?.fullSizeImageURL?.path ?? "")
|
||||
}
|
||||
})
|
||||
}
|
||||
return result.isEmpty ? nil : result
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
/// Returns the URL of the directory associated with the [destination] and [directory], or nil
|
||||
public func directoryForSharedStorage(destination: SharedStorage, directory: String) throws -> URL? {
|
||||
var dir: String
|
||||
switch destination {
|
||||
case .downloads:
|
||||
dir = "Downloads"
|
||||
case .audio:
|
||||
dir = "Music"
|
||||
case .images, .video:
|
||||
os_log("Destination .images and .video should use PhotoLibrary functions", log: log, type: .error)
|
||||
return nil
|
||||
case .files, .external:
|
||||
os_log("Destination .files and .external are not supported on iOS", log: log, type: .info)
|
||||
return nil
|
||||
|
||||
}
|
||||
let documentsURL =
|
||||
try? FileManager.default.url(for: FileManager.SearchPathDirectory.documentDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true)
|
||||
return directory.isEmpty
|
||||
? documentsURL?.appendingPath(dir, isDirectory: true)
|
||||
: documentsURL?.appendingPath(dir, isDirectory: true).appendingPath(directory, isDirectory: true)
|
||||
}
|
||||
|
||||
private func moveToFakeSharedStorage(filePath: String, destination: SharedStorage, directory: String, asUriString: Bool) -> String? {
|
||||
let fileUrl = NSURL(fileURLWithPath: filePath)
|
||||
guard let directory = try? directoryForSharedStorage(destination: destination, directory: directory) else {
|
||||
os_log("Cannot move to shared storage: no permission for directory %@", log: log, type: .error, directory)
|
||||
return nil
|
||||
}
|
||||
if !FileManager.default.fileExists(atPath: directory.path) {
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
os_log("Cannot move to shared storage: failed to create directory %@: %@", log: log, type: .error, directory.path, error.localizedDescription)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
let destUrl = directory.appendingPath((filePath as NSString).lastPathComponent)
|
||||
if FileManager.default.fileExists(atPath: destUrl.path) {
|
||||
try? FileManager.default.removeItem(at: destUrl)
|
||||
}
|
||||
do {
|
||||
try FileManager.default.moveItem(at: fileUrl as URL, to: destUrl)
|
||||
} catch {
|
||||
os_log("Failed to move file %@ to %@: %@", log: log, type: .error, filePath, destUrl.path, error.localizedDescription)
|
||||
return nil
|
||||
}
|
||||
return asUriString ? destUrl.absoluteString : destUrl.path
|
||||
}
|
||||
third_party/background_downloader/ios/background_downloader/Sources/background_downloader/Task.swift
Vendored
+328
@@ -0,0 +1,328 @@
|
||||
//
|
||||
// Task.swift
|
||||
// background_downloader
|
||||
//
|
||||
// Created on 3/4/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
/// Partial version of the Dart side DownloadTask, only used for background loading
|
||||
public struct Task : Codable, Hashable {
|
||||
public var taskId: String = "\(Int.random(in: 1..<(1 << 32)))"
|
||||
public var url: String
|
||||
public var urls: [String]? = []
|
||||
public var filename: String
|
||||
public var headers: [String:String] = [:]
|
||||
public var httpRequestMethod: String = "GET"
|
||||
public var chunks: Int? = 1
|
||||
public var post: String?
|
||||
public var postIsBinary: Bool?
|
||||
public var fileField: String?
|
||||
public var mimeType: String?
|
||||
public var fields: [String:String]?
|
||||
public var directory: String = ""
|
||||
public var baseDirectory: Int
|
||||
public var group: String
|
||||
public var updates: Int
|
||||
public var requiresWiFi: Bool = false
|
||||
public var retries: Int = 0
|
||||
public var retriesRemaining: Int = 0
|
||||
public var allowPause: Bool = false
|
||||
public var priority: Int = 5
|
||||
public var metaData: String = ""
|
||||
public var displayName: String = ""
|
||||
public var creationTime: Int64 = Int64((Date().timeIntervalSince1970 * 1000.0).rounded())
|
||||
var options: TaskOptions?
|
||||
var transferHints: [Int]?
|
||||
var stallTimeout: Int64?
|
||||
public var taskType: String
|
||||
}
|
||||
|
||||
extension Task {
|
||||
func copyWith(taskId: String? = nil,
|
||||
url: String? = nil,
|
||||
urls: [String]? = nil,
|
||||
filename: String? = nil,
|
||||
headers: [String:String]? = nil,
|
||||
httpRequestMethod: String? = nil,
|
||||
chunks: Int? = nil,
|
||||
post: String? = nil,
|
||||
fileField: String? = nil,
|
||||
mimeType: String? = nil,
|
||||
fields: [String:String]? = nil,
|
||||
directory: String? = nil,
|
||||
baseDirectory: Int? = nil,
|
||||
group: String? = nil,
|
||||
updates: Int? = nil,
|
||||
requiresWiFi: Bool? = nil,
|
||||
retries: Int? = nil,
|
||||
retriesRemaining: Int? = nil,
|
||||
allowPause: Bool? = nil,
|
||||
priority: Int? = nil,
|
||||
metaData: String? = nil,
|
||||
displayName: String? = nil,
|
||||
creationTime: Int64? = nil,
|
||||
options: TaskOptions? = nil,
|
||||
transferHints: [Int]? = nil,
|
||||
stallTimeout: Int64? = nil,
|
||||
taskType: String? = nil) -> Task {
|
||||
|
||||
var copiedTask = self
|
||||
|
||||
if let taskId = taskId {
|
||||
copiedTask.taskId = taskId
|
||||
}
|
||||
|
||||
if let url = url {
|
||||
copiedTask.url = url
|
||||
}
|
||||
|
||||
if let urls = urls {
|
||||
copiedTask.urls = urls
|
||||
}
|
||||
|
||||
if let filename = filename {
|
||||
copiedTask.filename = filename
|
||||
}
|
||||
|
||||
if let headers = headers {
|
||||
copiedTask.headers = headers
|
||||
}
|
||||
|
||||
if let httpRequestMethod = httpRequestMethod {
|
||||
copiedTask.httpRequestMethod = httpRequestMethod
|
||||
}
|
||||
|
||||
if let chunks = chunks {
|
||||
copiedTask.chunks = chunks
|
||||
}
|
||||
|
||||
if let post = post {
|
||||
copiedTask.post = post
|
||||
}
|
||||
|
||||
if let fileField = fileField {
|
||||
copiedTask.fileField = fileField
|
||||
}
|
||||
|
||||
if let mimeType = mimeType {
|
||||
copiedTask.mimeType = mimeType
|
||||
}
|
||||
|
||||
if let fields = fields {
|
||||
copiedTask.fields = fields
|
||||
}
|
||||
|
||||
if let directory = directory {
|
||||
copiedTask.directory = directory
|
||||
}
|
||||
|
||||
if let baseDirectory = baseDirectory {
|
||||
copiedTask.baseDirectory = baseDirectory
|
||||
}
|
||||
|
||||
if let group = group {
|
||||
copiedTask.group = group
|
||||
}
|
||||
|
||||
if let updates = updates {
|
||||
copiedTask.updates = updates
|
||||
}
|
||||
|
||||
if let requiresWiFi = requiresWiFi {
|
||||
copiedTask.requiresWiFi = requiresWiFi
|
||||
}
|
||||
|
||||
if let retries = retries {
|
||||
copiedTask.retries = retries
|
||||
}
|
||||
|
||||
if let retriesRemaining = retriesRemaining {
|
||||
copiedTask.retriesRemaining = retriesRemaining
|
||||
}
|
||||
|
||||
if let allowPause = allowPause {
|
||||
copiedTask.allowPause = allowPause
|
||||
}
|
||||
|
||||
if let priority = priority {
|
||||
copiedTask.priority = priority
|
||||
}
|
||||
|
||||
if let metaData = metaData {
|
||||
copiedTask.metaData = metaData
|
||||
}
|
||||
|
||||
if let displayName = displayName {
|
||||
copiedTask.displayName = displayName
|
||||
}
|
||||
|
||||
if let creationTime = creationTime {
|
||||
copiedTask.creationTime = creationTime
|
||||
}
|
||||
|
||||
if let options = options {
|
||||
copiedTask.options = options
|
||||
}
|
||||
|
||||
if let transferHints = transferHints {
|
||||
copiedTask.transferHints = transferHints
|
||||
}
|
||||
|
||||
if let stallTimeout = stallTimeout {
|
||||
copiedTask.stallTimeout = stallTimeout
|
||||
}
|
||||
|
||||
if let taskType = taskType {
|
||||
copiedTask.taskType = taskType
|
||||
}
|
||||
|
||||
return copiedTask
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: Int {
|
||||
case applicationDocuments, // getApplicationDocumentsDirectory()
|
||||
temporary, // getTemporaryDirectory()
|
||||
applicationSupport, // getApplicationSupportDirectory()
|
||||
applicationLibrary, // getLibraryDirectory()
|
||||
root // system root
|
||||
}
|
||||
|
||||
/// Type of updates requested for a group of downloads
|
||||
enum Updates: Int {
|
||||
case none, // no status or progress updates
|
||||
statusChange, // only calls upon change in DownloadTaskStatus
|
||||
progressUpdates, // only calls for progress
|
||||
statusChangeAndProgressUpdates // calls also for progress along the way
|
||||
}
|
||||
|
||||
/// Holds various options related to the task that are not included in the
|
||||
/// task's properties, as they are rare
|
||||
struct TaskOptions : Codable, Hashable {
|
||||
var beforeTaskStartRawHandle: Int64?
|
||||
var onTaskStartRawHandle: Int64?
|
||||
var onTaskFinishedRawHandle: Int64?
|
||||
var auth: Auth?
|
||||
|
||||
/// True if [TaskOptions] contains a callback for beforeTaskStart
|
||||
func hasBeforeStartCallback() -> Bool {
|
||||
beforeTaskStartRawHandle != nil
|
||||
}
|
||||
|
||||
/// True if [TaskOptions] contains a callback for onTaskStart
|
||||
func hasOnStartCallback() -> Bool {
|
||||
onTaskStartRawHandle != nil
|
||||
}
|
||||
|
||||
/// True if [TaskOptions] contains a callback that must be called after the task finishes
|
||||
func hasOnFinishedCallback() -> Bool {
|
||||
onTaskFinishedRawHandle != nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Defines a set of possible states which a [DownloadTask] can be in.
|
||||
public enum TaskStatus: Int, Codable {
|
||||
case enqueued,
|
||||
running,
|
||||
complete,
|
||||
notFound,
|
||||
failed,
|
||||
canceled,
|
||||
waitingToRetry,
|
||||
paused
|
||||
}
|
||||
|
||||
/** Holds data associated with a task status update, for local storage */
|
||||
public struct TaskStatusUpdate: Codable {
|
||||
public var task: Task
|
||||
public var taskStatus: TaskStatus
|
||||
public var exception: TaskException?
|
||||
public var responseBody: String?
|
||||
public var responseStatusCode: Int?
|
||||
public var responseHeaders: [String: String]?
|
||||
public var mimeType: String?
|
||||
public var charSet: String?
|
||||
|
||||
func argList() -> [Any?] {
|
||||
let finalState = isFinalState(status: taskStatus)
|
||||
return taskStatus == .failed
|
||||
? [taskStatus.rawValue,
|
||||
exception?.type.rawValue,
|
||||
exception?.description,
|
||||
exception?.httpResponseCode,
|
||||
responseBody]
|
||||
: [taskStatus.rawValue,
|
||||
finalState ? responseBody : nil,
|
||||
finalState ? responseHeaders : nil,
|
||||
(taskStatus == .complete || taskStatus == .notFound) ? responseStatusCode : nil,
|
||||
finalState ? mimeType : nil,
|
||||
finalState ? charSet : nil]
|
||||
}
|
||||
}
|
||||
|
||||
/** Holds data associated with a task progress update, for local storage */
|
||||
struct TaskProgressUpdate: Encodable {
|
||||
var task: Task
|
||||
var progress: Double
|
||||
var expectedFileSize: Int64
|
||||
}
|
||||
|
||||
/** Holds data associated with a resume, for local storage */
|
||||
struct ResumeData: Encodable {
|
||||
var task: Task
|
||||
var data: String
|
||||
}
|
||||
|
||||
/// The type of [TaskException]
|
||||
public enum ExceptionType: String, Codable {
|
||||
case
|
||||
|
||||
// General error
|
||||
general = "TaskException",
|
||||
|
||||
|
||||
// Could not save or find file, or create directory
|
||||
fileSystem = "TaskFileSystemException",
|
||||
|
||||
// URL incorrect
|
||||
url = "TaskUrlException",
|
||||
|
||||
// Connection problem, eg host not found, timeout
|
||||
connection = "TaskConnectionException",
|
||||
|
||||
// Could not resume or pause task
|
||||
resume = "TaskResumeException",
|
||||
|
||||
// Invalid HTTP response
|
||||
httpResponse = "TaskHttpException"
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains error information associated with a failed [Task]
|
||||
*
|
||||
* The [type] categorizes the error
|
||||
* The [httpResponseCode] is only valid if >0 and may offer details about the
|
||||
* nature of the error
|
||||
* The [description] is typically taken from the platform-generated
|
||||
* error message, or from the plugin. The localization is undefined
|
||||
*/
|
||||
public struct TaskException : Codable {
|
||||
public var type: ExceptionType
|
||||
public var httpResponseCode: Int = -1
|
||||
public var description: String
|
||||
}
|
||||
|
||||
func taskException(jsonString: String) -> TaskException {
|
||||
if let jsonMap = try! JSONSerialization.jsonObject(with: jsonString.data(using: .utf8)!, options: []) as? [String: Any] {
|
||||
return TaskException(type: ExceptionType(rawValue: jsonMap["type"] as! String) ?? ExceptionType.general
|
||||
, httpResponseCode: jsonMap["httpResponseCode"] as? Int ?? -1, description: jsonMap["description"] as! String )
|
||||
}
|
||||
return TaskException(type: .general, description: "Unknown error")
|
||||
}
|
||||
+735
@@ -0,0 +1,735 @@
|
||||
//
|
||||
// TaskFunctions.swift
|
||||
// background_downloader
|
||||
//
|
||||
// Created on 2/11/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Flutter
|
||||
import os.log
|
||||
|
||||
let updatesQueue = DispatchQueue(label: "updatesProcessingQueue")
|
||||
let separatorString = "***<<<|>>>***"
|
||||
|
||||
/// True if this task expects to provide progress updates
|
||||
func providesProgressUpdates(task: Task) -> Bool {
|
||||
return task.updates == Updates.progressUpdates.rawValue || task.updates == Updates.statusChangeAndProgressUpdates.rawValue
|
||||
}
|
||||
|
||||
/// True if this task expects to provide status updates
|
||||
func providesStatusUpdates(downloadTask: Task) -> Bool {
|
||||
return downloadTask.updates == Updates.statusChange.rawValue || downloadTask.updates == Updates.statusChangeAndProgressUpdates.rawValue
|
||||
}
|
||||
|
||||
/// True if this task is a DownloadTask, UriDownloadTask or ParallelDownloadTask
|
||||
func isDownloadTask(task: Task) -> Bool {
|
||||
return task.taskType == "DownloadTask" || task.taskType == "UriDownloadTask" || task.taskType == "ParallelDownloadTask"
|
||||
}
|
||||
|
||||
/// True if this task is a ParallelDownloadTask, false if not
|
||||
func isParallelDownloadTask(task: Task) -> Bool
|
||||
{
|
||||
return task.taskType == "ParallelDownloadTask"
|
||||
}
|
||||
|
||||
/// True if this task is an UploadTask, false if not
|
||||
///
|
||||
/// A MultiUploadTask is also an UploadTask
|
||||
func isUploadTask(task: Task) -> Bool {
|
||||
return task.taskType == "UploadTask" || task.taskType == "UriUploadTask" || task.taskType == "MultiUploadTask"
|
||||
}
|
||||
|
||||
/// True if this task is a MultiUploadTask, false if not
|
||||
func isMultiUploadTask(task: Task) -> Bool {
|
||||
return task.taskType == "MultiUploadTask"
|
||||
}
|
||||
|
||||
/// True if this task is a binary UploadTask
|
||||
func isBinaryUploadTask(task: Task) -> Bool {
|
||||
return isUploadTask(task: task) && task.post?.lowercased() == "binary"
|
||||
}
|
||||
|
||||
/// True if this is a DataTask
|
||||
func isDataTask(task: Task) -> Bool {
|
||||
return task.taskType == "DataTask"
|
||||
}
|
||||
|
||||
/// True if this state is not a final state (i.e. more changes may happen)
|
||||
func isNotFinalState(status: TaskStatus) -> Bool {
|
||||
return status == .enqueued || status == .running || status == .waitingToRetry || status == .paused
|
||||
}
|
||||
|
||||
/// True if this state is a final state (i.e. no more changes will happen)
|
||||
func isFinalState(status: TaskStatus) -> Bool {
|
||||
return !isNotFinalState(status: status)
|
||||
}
|
||||
|
||||
/// Returns the absolute path to the file represented by this task
|
||||
/// based on the [Task.filename] (default) or [withFilename]
|
||||
///
|
||||
/// If the task is a MultiUploadTask and no [withFilename] is given,
|
||||
/// returns the empty string, as there is no single path that can be
|
||||
/// returned
|
||||
func getFilePath(for task: Task, withFilename: String? = nil) -> String? {
|
||||
if isMultiUploadTask(task: task) && withFilename == nil {
|
||||
return ""
|
||||
}
|
||||
guard let directory = try? directoryForTask(task: task)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return directory.appendingPath(withFilename ?? task.filename).path
|
||||
}
|
||||
|
||||
func stripFileExtension ( _ filename: String ) -> String {
|
||||
var components = filename.components(separatedBy: ".")
|
||||
guard components.count > 1 else { return filename }
|
||||
components.removeLast()
|
||||
return components.joined(separator: ".")
|
||||
}
|
||||
|
||||
/// Suggests a filename based on response headers and a URL.
|
||||
/// If none can be derived, returns an empty string.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - responseHeaders: The response headers dictionary.
|
||||
/// - urlString: The URL string the file would be downloaded from.
|
||||
/// - Returns: A suggested filename, derived from the headers or the URL.
|
||||
func suggestFilename(responseHeaders: [AnyHashable: Any], urlString: String) -> String {
|
||||
do {
|
||||
if let disposition = responseHeaders["Content-Disposition"] as? String ?? responseHeaders["content-disposition"] as? String {
|
||||
// Try filename*=UTF-8'language'"encodedFilename"
|
||||
let encodedFilenameRegEx = try NSRegularExpression(pattern: #"filename\*=\s*([^']+)'([^']*)'"?([^"]+)"?"#, options: .caseInsensitive)
|
||||
let range = NSRange(location: 0, length: disposition.utf16.count)
|
||||
if let match = encodedFilenameRegEx.firstMatch(in: disposition, options: [], range: range) {
|
||||
if let encodingRange = Range(match.range(at: 1), in: disposition),
|
||||
let filenameRange = Range(match.range(at: 3), in: disposition) {
|
||||
let encoding = String(disposition[encodingRange]).uppercased()
|
||||
let filename = String(disposition[filenameRange])
|
||||
if encoding == "UTF-8" {
|
||||
if let decodedFilename = filename.removingPercentEncoding {
|
||||
return decodedFilename
|
||||
} else {
|
||||
os_log("Could not interpret suggested filename (UTF-8 url encoded) %@", log: log, type: .debug, filename)
|
||||
}
|
||||
} else {
|
||||
return filename
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try filename="filename"
|
||||
let plainFilenameRegEx = try NSRegularExpression(pattern: #"filename=\s*"?([^"]+)"?.*$"#, options: .caseInsensitive)
|
||||
if let match = plainFilenameRegEx.firstMatch(in: disposition, options: [], range: range) {
|
||||
if let filenameRange = Range(match.range(at: 1), in: disposition) {
|
||||
return String(disposition[filenameRange])
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
os_log("Error parsing suggested filename from headers: %@", log: log, type: .error, error.localizedDescription)
|
||||
}
|
||||
|
||||
os_log("Could not determine suggested filename from server", log: log, type: .debug)
|
||||
|
||||
// Try filename derived from last path segment of the url
|
||||
if let url = URL(string: urlString), !url.lastPathComponent.isEmpty {
|
||||
return url.lastPathComponent
|
||||
}
|
||||
|
||||
os_log("Could not parse URL pathSegment for suggested filename", log: log, type: .debug)
|
||||
return "" // Default fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* 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"
|
||||
*/
|
||||
func taskWithSuggestedFilenameFromResponseHeaders(
|
||||
task: Task,
|
||||
responseHeaders: [AnyHashable: Any],
|
||||
unique: Bool = false
|
||||
) -> Task {
|
||||
let suggestedFilenameValue = suggestFilename(responseHeaders: responseHeaders, urlString: task.url)
|
||||
|
||||
if !suggestedFilenameValue.isEmpty {
|
||||
return uniqueFilename(task: task.copyWith(filename: suggestedFilenameValue), unique: unique)
|
||||
}
|
||||
|
||||
// If everything fails, return the task with an unchanged filename
|
||||
// except for possibly making it unique
|
||||
return uniqueFilename(task: task, unique: unique)
|
||||
}
|
||||
|
||||
/// Returns [Task] 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]
|
||||
func uniqueFilename(task: Task, unique: Bool) -> Task {
|
||||
if !unique {
|
||||
return task
|
||||
}
|
||||
let sequenceRegEx = try! NSRegularExpression(pattern: #"\((\d+)\)\.?[^.]*$"#)
|
||||
let extensionRegEx = try! NSRegularExpression(pattern: #"\.[^.]*$"#)
|
||||
var newTask = task
|
||||
guard let filePath = getFilePath(for: task) else {
|
||||
return task
|
||||
}
|
||||
var exists = FileManager.default.fileExists(atPath: filePath)
|
||||
while exists {
|
||||
let range = NSMakeRange(0, newTask.filename.utf16.count)
|
||||
let extMatch = extensionRegEx.firstMatch(in: newTask.filename, options: [], range: range)
|
||||
let extString = extMatch != nil ? String(newTask.filename[Range(extMatch!.range, in: newTask.filename)!]) : ""
|
||||
let seqMatch = sequenceRegEx.firstMatch(in: newTask.filename, options: [], range: range)
|
||||
let seqString = seqMatch != nil ? String(newTask.filename[Range(seqMatch!.range(at: 1), in: newTask.filename)!]) : ""
|
||||
let newSequence = seqString.isEmpty ? 1 : Int(seqString)! + 1
|
||||
let newFilename: String
|
||||
if seqMatch == nil {
|
||||
let baseNameWithoutExtension = stripFileExtension(newTask.filename)
|
||||
newFilename = "\(baseNameWithoutExtension) (\(newSequence))\(extString)"
|
||||
} else {
|
||||
let startOfSeq = seqMatch!.range.location
|
||||
let index = newTask.filename.index(newTask.filename.startIndex, offsetBy: startOfSeq)
|
||||
newFilename = "\(newTask.filename.prefix(upTo: index))(\(newSequence))\(extString)"
|
||||
}
|
||||
newTask = newTask.copyWith(filename: newFilename)
|
||||
guard let filePath = getFilePath(for: newTask) else {
|
||||
return task
|
||||
}
|
||||
exists = FileManager.default.fileExists(atPath: filePath)
|
||||
}
|
||||
return newTask
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 0L, and a missing upper
|
||||
* range with null. If the string cannot be parsed, returns (0L, null)
|
||||
*/
|
||||
func parseRange(rangeStr: String) -> (Int64, Int64?) {
|
||||
let regex = try! NSRegularExpression(pattern: #"bytes=(\d*)-(\d*)"#)
|
||||
let range = NSMakeRange(0, rangeStr.utf16.count)
|
||||
if let match = regex.firstMatch(in: rangeStr, options: [], range: range) {
|
||||
let start = Int64(String(rangeStr[Range(match.range(at: 1), in: rangeStr)!]))
|
||||
let end = Int64(String(rangeStr[Range(match.range(at: 2), in: rangeStr)!]))
|
||||
return (start!, end!)
|
||||
} else {
|
||||
return (0, nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the content length extracted from the [responseHeaders], or from
|
||||
/// the [task] headers
|
||||
func getContentLength(responseHeaders: [AnyHashable: Any], task: Task) -> Int64 {
|
||||
// On iOS, the header has already been parsed for Content-Length so we don't need to
|
||||
// repeat that here (actually, we use the bytesExpectedToSend which is for some reason
|
||||
// not always set even whe a Content-Length is set)
|
||||
// Try extracting content length from Range header
|
||||
let taskRangeHeader = task.headers["Range"] ?? ""
|
||||
let taskRange = parseRange(rangeStr: taskRangeHeader)
|
||||
if let end = taskRange.1 {
|
||||
let rangeLength = end - taskRange.0 + 1
|
||||
os_log("TaskId %@ contentLength set to %d based on Range header", log: log, type: .info, task.taskId, rangeLength)
|
||||
return rangeLength
|
||||
}
|
||||
|
||||
// try extracting it from a special "Known-Content-Length" header
|
||||
let knownLength = Int64(task.headers["Known-Content-Length"] ?? "-1") ?? -1
|
||||
if knownLength != -1 {
|
||||
os_log("TaskId %@ contentLength set to %d based on Known-Content-Length header", log: log, type: .info, task.taskId, knownLength)
|
||||
} else {
|
||||
os_log("TaskId %@ contentLength undetermined", log: log, type: .info, task.taskId)
|
||||
}
|
||||
return knownLength
|
||||
}
|
||||
|
||||
/// Sets the mimeType and charSet extracted from the Content-Type header
|
||||
/// in [responseHeaders] and stores in static maps keyed by [task.taskId]
|
||||
func extractContentType(responseHeaders: [AnyHashable: Any], task: Task) {
|
||||
guard let contentType = responseHeaders["Content-Type"] as? String else { return }
|
||||
let regEx = try! NSRegularExpression(pattern: #"(.*);\s*charset\s*=(.*)"#)
|
||||
let range = NSMakeRange(0, contentType.utf16.count)
|
||||
let match = regEx.firstMatch(in: contentType, options: [], range: range)
|
||||
BDPlugin.propertyLock.withLock({
|
||||
if let match = match {
|
||||
let mimeType = String(contentType[Range(match.range(at: 1), in: contentType)!])
|
||||
let charSet = String(contentType[Range(match.range(at: 2), in: contentType)!])
|
||||
BDPlugin.mimeTypes[task.taskId] = mimeType
|
||||
BDPlugin.charSets[task.taskId] = charSet
|
||||
} else {
|
||||
BDPlugin.mimeTypes[task.taskId] = contentType
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Returns a list of fileData elements, one for each file to upload.
|
||||
/// Each element is a triple containing fileField, full filePath, mimeType
|
||||
///
|
||||
/// The lists are stored in the similarly named String fields as a JSON list,
|
||||
/// with each list the same length. For the filenames list, if a filename refers
|
||||
/// to a file that exists (i.e. it is a full path) then that is the filePath used,
|
||||
/// otherwise the filename is appended to the [Task.baseDirectory] and [Task.directory]
|
||||
/// to form a full file path
|
||||
func extractFilesData(task: Task) -> [((String, String, String))] {
|
||||
let decoder = JSONDecoder()
|
||||
guard
|
||||
let fileFields = try? decoder.decode([String].self, from: task.fileField!.data(using: .utf8)!),
|
||||
let filenames = try? decoder.decode([String].self, from: task.filename.data(using: .utf8)!),
|
||||
let mimeTypes = try? decoder.decode([String].self, from: task.mimeType!.data(using: .utf8)!)
|
||||
else {
|
||||
os_log("Could not parse filesData from field=%@, filename=%@ and mimeType=%@", log: log, type: .error, task.fileField!, task.filename, task.mimeType!)
|
||||
return []
|
||||
}
|
||||
var result = [(String, String, String)]()
|
||||
for i in 0 ..< fileFields.count {
|
||||
let fileUrl = URL(string: filenames[i])
|
||||
let decodedFileUrl = fileUrl != nil ? decodeToFileUrl(uri: fileUrl!) : nil
|
||||
if decodedFileUrl?.scheme == "file" {
|
||||
// URI mode, return path
|
||||
result.append((fileFields[i], decodedFileUrl!.path, mimeTypes[i]))
|
||||
} else {
|
||||
// file path mode
|
||||
if FileManager.default.fileExists(atPath: filenames[i]) {
|
||||
result.append((fileFields[i], filenames[i], mimeTypes[i]))
|
||||
} else {
|
||||
result.append((
|
||||
fileFields[i],
|
||||
getFilePath(for: task, withFilename: filenames[i]) ?? "",
|
||||
mimeTypes[i]
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Return the host name for the task's url or ""
|
||||
func getHost(_ task: Task) -> String {
|
||||
return URL(string: task.url)?.host ?? ""
|
||||
}
|
||||
|
||||
/// Calculate progress, network speed and time remaining, and send this at an appropriate
|
||||
/// interval to the Dart side
|
||||
func updateProgress(task: Task, totalBytesExpected: Int64, totalBytesDone: Int64) {
|
||||
let info = BDPlugin.propertyLock.withLock({
|
||||
return BDPlugin.progressInfo[task.taskId] ?? (lastProgressUpdateTime: 0.0, lastProgressValue: 0.0, lastTotalBytesDone: 0, lastNetworkSpeed: -1.0)
|
||||
})
|
||||
let now = Date().timeIntervalSince1970
|
||||
if totalBytesExpected != NSURLSessionTransferSizeUnknown && now > info.lastProgressUpdateTime + 0.5 {
|
||||
let progress = min(Double(totalBytesDone) / Double(totalBytesExpected), 0.999)
|
||||
if (progress - info.lastProgressValue > 0.02) || (progress > info.lastProgressValue && now > info.lastProgressUpdateTime + 2.5) {
|
||||
// calculate network speed and time remaining
|
||||
let now = Date().timeIntervalSince1970
|
||||
let timeSinceLastUpdate = now - info.lastProgressUpdateTime
|
||||
let bytesSinceLastUpdate = totalBytesDone - info.lastTotalBytesDone
|
||||
let currentNetworkSpeed: Double = timeSinceLastUpdate > 3600 ? -1.0 : Double(bytesSinceLastUpdate) / timeSinceLastUpdate / 1000000.0
|
||||
let newNetworkSpeed = info.lastNetworkSpeed == -1.0 ? currentNetworkSpeed : (info.lastNetworkSpeed * 3.0 + currentNetworkSpeed) / 4.0
|
||||
let remainingBytes = (1.0 - progress) * Double(totalBytesExpected)
|
||||
let timeRemaining: TimeInterval = newNetworkSpeed == -1.0 ? -1.0 : (remainingBytes / newNetworkSpeed / 1000000.0)
|
||||
BDPlugin.propertyLock.withLock({
|
||||
BDPlugin.progressInfo[task.taskId] = (lastProgressUpdateTime: now, lastProgressValue: progress, lastTotalBytesDone: totalBytesDone, lastNetworkSpeed: newNetworkSpeed)
|
||||
})
|
||||
processProgressUpdate(task: task, progress: progress, expectedFileSize: totalBytesExpected, networkSpeed: newNetworkSpeed, timeRemaining: timeRemaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Processes a change in status for the task
|
||||
///
|
||||
/// Sends status update via the background channel to Dart, if requested
|
||||
/// If the task is finished, processes a final progressUpdate update and removes
|
||||
/// task from persistent storage
|
||||
func processStatusUpdate(task: Task, status: TaskStatus, taskException: TaskException? = nil, responseBody: String? = nil, responseHeaders: [AnyHashable:Any]? = nil, responseStatusCode: Int? = nil, mimeType: String? = nil, charSet: String? = nil) {
|
||||
// Intercept status updates resulting from re-enqueue requests, which
|
||||
// themselves are triggered by a change in WiFi requirement
|
||||
let intercepted = BDPlugin.propertyLock.withLock {
|
||||
if BDPlugin.tasksToReEnqueue.remove(task) != nil {
|
||||
defer {
|
||||
if BDPlugin.tasksToReEnqueue.isEmpty {
|
||||
WiFiQueue.shared.reEnqueue(nil) // signal end of batch
|
||||
}
|
||||
}
|
||||
if [TaskStatus.paused, TaskStatus.canceled, TaskStatus.failed].contains(status) {
|
||||
_ = BDPlugin.progressInfo.removeValue(forKey: task.taskId) // ensure .running update on re-enqueue
|
||||
BDPlugin.initialResponseDataProcessed.remove(task.taskId)
|
||||
let resumeDataAsBase64String = status == .paused ? (BDPlugin.localResumeData[task.taskId] ?? "") : ""
|
||||
if status != .paused {
|
||||
BDPlugin.localResumeData.removeValue(forKey: task.taskId)
|
||||
}
|
||||
let reEnqueueData = EnqueueItem(task: task, notificationConfigJsonString: BDPlugin.notificationConfigJsonStrings[task.taskId], resumeDataAsBase64String: resumeDataAsBase64String)
|
||||
WiFiQueue.shared.reEnqueue(reEnqueueData)
|
||||
return true // intercepted
|
||||
}
|
||||
}
|
||||
return false // not intercepted
|
||||
}
|
||||
if intercepted {
|
||||
return
|
||||
}
|
||||
|
||||
// Normal status update
|
||||
|
||||
// Post update if task expects one, or if failed and retry is needed
|
||||
let 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 on the Dart side
|
||||
switch (status) {
|
||||
case .complete:
|
||||
processProgressUpdate(task: task, progress: 1.0)
|
||||
case .failed:
|
||||
if !retryNeeded {
|
||||
processProgressUpdate(task: task, progress: -1.0)
|
||||
}
|
||||
case .canceled:
|
||||
processProgressUpdate(task: task, progress: -2.0)
|
||||
case .notFound:
|
||||
processProgressUpdate(task: task, progress: -3.0)
|
||||
case .paused:
|
||||
processProgressUpdate(task: task, progress: -5.0)
|
||||
default:
|
||||
break
|
||||
}
|
||||
// determine the TaskStatusUpdate
|
||||
let finalResponseStatusCode = status == .complete || status == .notFound
|
||||
? responseStatusCode
|
||||
: nil
|
||||
let finalTaskException = taskException == nil
|
||||
? TaskException(type: .general, httpResponseCode: -1, description: "")
|
||||
: taskException
|
||||
let statusUpdate = isFinalState(status: status)
|
||||
? TaskStatusUpdate(task: task,
|
||||
taskStatus: status,
|
||||
exception: status == .failed ? finalTaskException : nil,
|
||||
responseBody: responseBody,
|
||||
responseStatusCode: (status == .complete || status == .notFound) ? finalResponseStatusCode : nil,
|
||||
responseHeaders: lowerCasedStringStringMap(responseHeaders),
|
||||
mimeType: mimeType,
|
||||
charSet: charSet)
|
||||
: TaskStatusUpdate(task: task, taskStatus: status)
|
||||
if let onNativeTaskStatusChange = BDPlugin.onNativeTaskStatusChange {
|
||||
onNativeTaskStatusChange(task, statusUpdate)
|
||||
}
|
||||
if providesStatusUpdates(downloadTask: task) || retryNeeded {
|
||||
let arg = statusUpdate.argList()
|
||||
if !postOnBackgroundChannel(method: "statusUpdate", task: task, arg: arg) {
|
||||
// store update locally as a merged task/status JSON string, without error info
|
||||
guard let jsonData = try? JSONEncoder().encode(statusUpdate)
|
||||
else {
|
||||
os_log("Could not store status update locally", log: log, type: .debug)
|
||||
return }
|
||||
storeLocally(prefsKey: BDPlugin.keyStatusUpdateMap, taskId: task.taskId, item: jsonData)
|
||||
}
|
||||
}
|
||||
if isFinalState(status: status) {
|
||||
// remove references to this task that are no longer needed
|
||||
BDPlugin.propertyLock.withLock {
|
||||
BDPlugin.progressInfo.removeValue(forKey: task.taskId)
|
||||
BDPlugin.localResumeData.removeValue(forKey: task.taskId)
|
||||
BDPlugin.remainingBytesToDownload.removeValue(forKey: task.taskId)
|
||||
BDPlugin.tasksWithModifications.removeValue(forKey: task.taskId)
|
||||
BDPlugin.tasksWithContentLengthOverride.removeValue(forKey: task.taskId)
|
||||
BDPlugin.responseBodyData.removeValue(forKey: task.taskId)
|
||||
BDPlugin.notificationConfigJsonStrings.removeValue(forKey: task.taskId)
|
||||
BDPlugin.taskIdsThatCanResume.remove(task.taskId)
|
||||
BDPlugin.taskIdsProgrammaticallyCanceledAfterStart.remove(task.taskId)
|
||||
}
|
||||
// invoke onTaskFinished callback if needed
|
||||
if task.options?.hasOnFinishedCallback() == true {
|
||||
_Concurrency.Task {
|
||||
if !(await invokeOnTaskFinishedCallback(taskStatusUpdate: statusUpdate)) {
|
||||
os_log("Could not invoke onTaskFinishedCallback", log: log, type: .error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Processes a progress update for the task
|
||||
///
|
||||
/// Sends progress update via the background channel to Dart, if requested
|
||||
func processProgressUpdate(task: Task, progress: Double, expectedFileSize: Int64 = -1, networkSpeed: Double = -1.0, timeRemaining: TimeInterval = -1.0) {
|
||||
if let onNativeTaskProgressChange = BDPlugin.onNativeTaskProgressChange {
|
||||
onNativeTaskProgressChange(task, progress)
|
||||
}
|
||||
if providesProgressUpdates(task: task) {
|
||||
if (!postOnBackgroundChannel(method: "progressUpdate", task: task, arg: [progress, expectedFileSize, networkSpeed, Int(timeRemaining * 1000.0)] as [Any])) {
|
||||
// store update locally as a merged task/progress JSON string
|
||||
guard let jsonData = try? JSONEncoder().encode(TaskProgressUpdate(task: task, progress: progress, expectedFileSize: expectedFileSize))
|
||||
else {
|
||||
os_log("Could not store progress update locally", log: log, type: .info)
|
||||
return }
|
||||
storeLocally(prefsKey: BDPlugin.keyProgressUpdateMap, taskId: task.taskId, item: jsonData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a 'canResume' message for the task
|
||||
///
|
||||
/// Sends the data via the background channel to Dart
|
||||
func processCanResume(task: Task, taskCanResume: Bool) {
|
||||
if !postOnBackgroundChannel(method: "canResume", task: task, arg: taskCanResume) {
|
||||
os_log("Could not post CanResume", log: log, type: .info)
|
||||
}
|
||||
}
|
||||
|
||||
/// Post resume data for this task
|
||||
///
|
||||
/// Returns true if successful.
|
||||
/// Sends the data via the background channel to Dart
|
||||
func processResumeData(task: Task, resumeData: Data) -> Bool {
|
||||
let resumeDataAsBase64String = resumeData.base64EncodedString()
|
||||
BDPlugin.propertyLock.withLock {
|
||||
BDPlugin.localResumeData[task.taskId] = resumeDataAsBase64String
|
||||
}
|
||||
if !postOnBackgroundChannel(method: "resumeData", task: task, arg: resumeDataAsBase64String) {
|
||||
// store resume data locally
|
||||
guard let jsonData = try? JSONEncoder().encode(ResumeData(task: task, data: resumeDataAsBase64String))
|
||||
else {
|
||||
os_log("Could not store resume data locally", log: log, type: .info)
|
||||
return false}
|
||||
storeLocally(prefsKey: BDPlugin.keyResumeDataMap, taskId: task.taskId, item: jsonData)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Return the background channel for cummincation to Dart side, or nil
|
||||
func getBackgroundChannel() -> FlutterMethodChannel? {
|
||||
guard let channel = BDPlugin.backgroundChannel else {
|
||||
os_log("Could not find background channel", log: log, type: .error)
|
||||
return nil
|
||||
}
|
||||
return channel
|
||||
}
|
||||
|
||||
/// Post method message on backgroundChannel with arguments and return true if this was successful
|
||||
///
|
||||
/// [arg] can be a list or a single variable
|
||||
func postOnBackgroundChannel(method: String, task:Task, arg: Any) -> Bool {
|
||||
guard let channel = BDPlugin.backgroundChannel else {
|
||||
os_log("Could not find background channel", log: log, type: .error)
|
||||
return false
|
||||
}
|
||||
guard let jsonString = jsonStringFor(task: task) else {
|
||||
os_log("Could not convert task to JSON", log: log, type: .error)
|
||||
return false
|
||||
}
|
||||
var argsList: [Any?] = [jsonString]
|
||||
if arg is [Any?] {
|
||||
argsList.append(contentsOf: arg as! [Any?])
|
||||
} else {
|
||||
argsList.append(arg)
|
||||
}
|
||||
if Thread.isMainThread {
|
||||
DispatchQueue.main.async {
|
||||
channel.invokeMethod(method, arguments: argsList)
|
||||
}
|
||||
return true
|
||||
}
|
||||
var success = false
|
||||
updatesQueue.sync {
|
||||
let dispatchGroup = DispatchGroup()
|
||||
dispatchGroup.enter()
|
||||
DispatchQueue.main.async {
|
||||
channel.invokeMethod(method, arguments: argsList, result: {(r: Any?) -> () in
|
||||
success = !(r is FlutterError)
|
||||
if BDPlugin.forceFailPostOnBackgroundChannel {
|
||||
success = false
|
||||
}
|
||||
dispatchGroup.leave()
|
||||
})
|
||||
}
|
||||
dispatchGroup.wait()
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
/// Store the [item] in preferences under [prefsKey], keyed by [taskId]
|
||||
///
|
||||
/// [item] is a JsonEncoded Data object
|
||||
func storeLocally(prefsKey: String, taskId: String,
|
||||
item: Data) {
|
||||
let defaults = UserDefaults.standard
|
||||
var map = defaults.dictionary(forKey: prefsKey) ?? [:]
|
||||
map[taskId] = String(data: item, encoding: .utf8)
|
||||
defaults.set(map, forKey: prefsKey)
|
||||
}
|
||||
|
||||
/// Returns a JSON string for this Task, or nil
|
||||
func jsonStringFor(task: Task) -> String? {
|
||||
let jsonEncoder = JSONEncoder()
|
||||
guard let jsonResultData = try? jsonEncoder.encode(task)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return String(data: jsonResultData, encoding: .utf8)
|
||||
}
|
||||
|
||||
/// Returns a JSON string for this TaskStatusUpdate, or nil
|
||||
func jsonStringFor(taskStatusUpdate: TaskStatusUpdate) -> String? {
|
||||
let jsonEncoder = JSONEncoder()
|
||||
guard let jsonResultData = try? jsonEncoder.encode(taskStatusUpdate)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return String(data: jsonResultData, encoding: .utf8)
|
||||
}
|
||||
|
||||
/// Returns a Task from the supplied jsonString, or nil
|
||||
func taskFrom(jsonString: String) -> Task? {
|
||||
let decoder = JSONDecoder()
|
||||
let task: Task? = try? decoder.decode(Task.self, from: (jsonString).data(using: .utf8)!)
|
||||
return task
|
||||
}
|
||||
|
||||
/// Return the task corresponding to the URLSessionTask, or nil if it cannot be matched
|
||||
///
|
||||
/// May return a modified version of the task instead of the original stored with the URLSessionTask, e.g.
|
||||
/// if the filename has changed
|
||||
func getTaskFrom(urlSessionTask: URLSessionTask) -> Task? {
|
||||
guard let jsonData = getTaskJsonStringFrom(urlSessionTask: urlSessionTask)?.data(using: .utf8)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let decoder = JSONDecoder()
|
||||
if let task = try? decoder.decode(Task.self, from: jsonData) {
|
||||
let modifiedTask = BDPlugin.propertyLock.withLock({
|
||||
BDPlugin.tasksWithModifications[task.taskId]
|
||||
})
|
||||
return modifiedTask ?? task
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Marks the task as modified such that [getTaskFrom] a UrlSession wlll get the modified task, not the one'
|
||||
/// immutably stored with the urlSession
|
||||
func storeModifiedTask(task: Task) {
|
||||
BDPlugin.propertyLock.withLock({
|
||||
BDPlugin.tasksWithModifications[task.taskId] = task
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the taskJsonString contained in the urlSessionTask
|
||||
func getTaskJsonStringFrom(urlSessionTask: URLSessionTask) -> String? {
|
||||
guard let taskDescription = urlSessionTask.taskDescription else {
|
||||
return nil
|
||||
}
|
||||
if taskDescription.contains(separatorString) {
|
||||
return taskDescription.components(separatedBy: separatorString)[0]
|
||||
}
|
||||
return taskDescription
|
||||
}
|
||||
|
||||
/// Return the notificationConfig corresponding to the URLSessionTask, or nil if it cannot be matched
|
||||
func getNotificationConfigFrom(urlSessionTask: URLSessionTask) -> NotificationConfig? {
|
||||
guard let jsonData = getNotificationConfigJsonStringFrom(urlSessionTask: urlSessionTask)?.data(using: .utf8)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let decoder = JSONDecoder()
|
||||
return try? decoder.decode(NotificationConfig.self, from: jsonData)
|
||||
}
|
||||
|
||||
|
||||
/// Returns the notificationConfigJsonString contained in the urlSessionTask
|
||||
func getNotificationConfigJsonStringFrom(urlSessionTask: URLSessionTask) -> String? {
|
||||
guard let taskDescription = urlSessionTask.taskDescription else {
|
||||
return nil
|
||||
}
|
||||
if taskDescription.contains(separatorString) {
|
||||
return taskDescription.components(separatedBy: separatorString)[1]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Returns the URL of the directory where the file for this task is stored
|
||||
///
|
||||
/// This is made up of the baseDirectory and the directory fields of the Task
|
||||
func directoryForTask(task: Task) throws -> URL {
|
||||
let documentsURL: URL
|
||||
if task.baseDirectory != BaseDirectory.root.rawValue {
|
||||
var dir: FileManager.SearchPathDirectory
|
||||
switch task.baseDirectory {
|
||||
case 0:
|
||||
dir = .documentDirectory
|
||||
case 1:
|
||||
dir = .cachesDirectory
|
||||
case 2:
|
||||
dir = .applicationSupportDirectory
|
||||
case 3:
|
||||
dir = .libraryDirectory
|
||||
default:
|
||||
dir = .documentDirectory
|
||||
}
|
||||
documentsURL =
|
||||
try FileManager.default.url(for: dir,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true)
|
||||
} else {
|
||||
documentsURL = URL(fileURLWithPath: "/")
|
||||
}
|
||||
return task.directory.isEmpty
|
||||
? documentsURL
|
||||
: documentsURL.appendingPath(task.directory, isDirectory: true)
|
||||
|
||||
}
|
||||
|
||||
/// True if task requires WiFi, based on global and task-specific setting
|
||||
func taskRequiresWiFi(task: Task) -> Bool {
|
||||
return BDPlugin.requireWiFi == .forAllTasks || (BDPlugin.requireWiFi == .asSetByTask && task.requiresWiFi)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if there is insufficient space to store a file of length
|
||||
* [contentLength]
|
||||
*
|
||||
* Returns false if [contentLength] <= 0
|
||||
* Returns false if configCheckAvailableSpace has not been set, or if available
|
||||
* space is greater than that setting
|
||||
* Returns true otherwise
|
||||
*/
|
||||
func insufficientSpace(contentLength: Int64) -> Bool {
|
||||
guard contentLength > 0 else {
|
||||
return false
|
||||
}
|
||||
let checkValue = UserDefaults.standard.integer(forKey: BDPlugin.keyConfigCheckAvailableSpace)
|
||||
guard
|
||||
// Check if the configCheckAvailableSpace preference is set and is positive
|
||||
checkValue > 0,
|
||||
let path = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first,
|
||||
let available = try? URL(fileURLWithPath: path).resourceValues(forKeys: [URLResourceKey.volumeAvailableCapacityForImportantUsageKey]).volumeAvailableCapacityForImportantUsage
|
||||
else {
|
||||
return false
|
||||
}
|
||||
// Calculate the total remaining bytes to download
|
||||
let remainingBytesToDownload = BDPlugin.propertyLock.withLock( {
|
||||
BDPlugin.remainingBytesToDownload.values.reduce(0, +)
|
||||
})
|
||||
// Return true if there is insufficient space to store the file
|
||||
return available - (remainingBytesToDownload + contentLength) < checkValue << 20
|
||||
}
|
||||
|
||||
/// Post result [value] on FlutterResult completer
|
||||
func postResult(result: FlutterResult?, value: Any) {
|
||||
if result != nil {
|
||||
result!(value)
|
||||
}
|
||||
}
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
//
|
||||
// Uploader.swift
|
||||
// background_downloader
|
||||
//
|
||||
// Created on 2/11/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
enum StreamError: Error {
|
||||
case Closed
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Uploader associated with one URLSessionUploadTask
|
||||
public class Uploader : NSObject, URLSessionTaskDelegate, StreamDelegate {
|
||||
|
||||
var task: Task
|
||||
let outputFilename: String
|
||||
var contentDispositionString = ""
|
||||
var contentTypeString = ""
|
||||
var fieldsString = ""
|
||||
var totalBytesWritten: Int64 = 0
|
||||
static let boundary = "-----background_downloader-akjhfw281onqciyhnIk"
|
||||
let lineFeed = "\r\n"
|
||||
let asciiOnly = try! NSRegularExpression(pattern: "^[\\x00-\\x7F]+$")
|
||||
let jsonString = try! NSRegularExpression(pattern: "^\\s*(\\{.*\\}|\\[.*\\])\\s*$")
|
||||
let newlineRegExp = try! NSRegularExpression(pattern: "\r\n|\r|\n")
|
||||
let bufferSize = 2 << 13
|
||||
|
||||
|
||||
/// Initialize an Uploader for this Task
|
||||
init(task: Task) {
|
||||
self.task = task
|
||||
outputFilename = NSUUID().uuidString
|
||||
}
|
||||
|
||||
|
||||
/// Creates the multipart file so it can be uploaded
|
||||
///
|
||||
/// Returns true if successful, false otherwise (cleaning up any partial file on failure).
|
||||
public func createMultipartFile() -> Bool {
|
||||
if writeMultipartFile() {
|
||||
return true
|
||||
}
|
||||
try? FileManager.default.removeItem(at: outputFileUrl())
|
||||
return false
|
||||
}
|
||||
|
||||
/// Writes the multipart file, returning true if successful
|
||||
private func writeMultipartFile() -> Bool {
|
||||
// create the output file
|
||||
FileManager.default.createFile(atPath: outputFileUrl().path, contents:Data(" ".utf8), attributes: nil)
|
||||
guard let fileHandle = try? FileHandle(forWritingTo: outputFileUrl()) else {
|
||||
os_log("Could not open temporary file %@", log: log, type: .error, outputFileUrl().path)
|
||||
return false
|
||||
}
|
||||
// field portion of the multipart, all in one string
|
||||
// multiple values should be encoded as '"value1", "value2", ...'
|
||||
let multiValueRegEx = try! NSRegularExpression(pattern: #"^(?:"[^"]+"\s*,\s*)+"[^"]+"$"#, options: [])
|
||||
for entry in task.fields ?? [:] {
|
||||
let value = entry.value
|
||||
let fullRange = NSRange(location: 0, length: value.utf16.count)
|
||||
// Check if the value matches the multi-value format
|
||||
if multiValueRegEx.firstMatch(in: value, options: [], range: fullRange) != nil {
|
||||
// Extract multiple values from entry.value
|
||||
let valueMatchRegEx = try! NSRegularExpression(pattern: #""([^"]+)""#, options: [])
|
||||
valueMatchRegEx.enumerateMatches(in: value, options: [], range: fullRange) { match, _, _ in
|
||||
if let matchRange = match?.range(at: 1), // Capture group 1
|
||||
let swiftRange = Range(matchRange, in: value) {
|
||||
let matchedString = String(value[swiftRange])
|
||||
fieldsString += fieldEntry(name: entry.key, value: matchedString)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Handle single value for key
|
||||
fieldsString += fieldEntry(name: entry.key, value: entry.value)
|
||||
}
|
||||
}
|
||||
if (!writeFields(fileHandle: fileHandle)) {
|
||||
os_log("Could not write to temporary file %@", log: log, type: .error, outputFileUrl().path)
|
||||
return false
|
||||
}
|
||||
// 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
|
||||
let separator = "\(lineFeed)--\(Uploader.boundary)\(lineFeed)" // between files
|
||||
let terminator = "\(lineFeed)--\(Uploader.boundary)--\(lineFeed)" // after last file
|
||||
let maybeFileUri = uriFromStringValue(maybePacked: task.filename)
|
||||
let maybeDecodedFileUri = maybeFileUri != nil ? decodeToFileUrl(uri: maybeFileUri!) : nil
|
||||
guard let filePath = getFilePath(for: task) else {return false}
|
||||
let filesData = filePath.isEmpty
|
||||
? extractFilesData(task: task) // MultiUpload case
|
||||
: [(task.fileField!,maybeDecodedFileUri?.path ?? filePath, task.mimeType!)] // one file Upload case
|
||||
for (fileField, path, mimeType) in filesData {
|
||||
if !FileManager.default.fileExists(atPath: path) {
|
||||
os_log("File to upload does not exist at %@", log: log, type: .error, path)
|
||||
return false
|
||||
}
|
||||
let derivedFilename = path.components(separatedBy: "/").last!
|
||||
if filesData.count == 1 && task.taskType != "MultiUploadTask" {
|
||||
// only for single file uploads do we set the task's filename property
|
||||
let newTask = task.copyWith(filename: maybeFileUri != nil
|
||||
? pack(filename: derivedFilename, uri: maybeFileUri!)
|
||||
: derivedFilename)
|
||||
storeModifiedTask(task: newTask)
|
||||
}
|
||||
let contentDispositionString =
|
||||
"Content-Disposition: form-data; name=\"\(browserEncode(fileField))\"; "
|
||||
+ "filename=\"\(browserEncode(derivedFilename))\"\(lineFeed)"
|
||||
let resolvedMimeType = mimeType.isEmpty ? getMimeType(fromFilename: path) : mimeType
|
||||
let contentTypeString = "Content-Type: \(resolvedMimeType)\(lineFeed)\(lineFeed)"
|
||||
let fileUrl = URL(fileURLWithPath: path)
|
||||
guard let inputStream = InputStream(url: fileUrl) else {
|
||||
os_log("Could not open file to upload at %@", log: log, type: .error, path)
|
||||
return false
|
||||
}
|
||||
if (!writeFileBytes(fileHandle: fileHandle, inputStream: inputStream, contentDisposition: contentDispositionString, contentType: contentTypeString)) {
|
||||
os_log("Could not upload file at %@", log: log, type: .error, path)
|
||||
return false
|
||||
}
|
||||
if (!writeText(fileHandle: fileHandle, text: path == filesData.last!.1 ?
|
||||
terminator : separator)) {
|
||||
os_log("Could not write separator for file at %@", log: log, type: .error, path)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Return the URL of the generated outputfile with multipart data
|
||||
///
|
||||
/// Callers outside this class should only use this after calling
|
||||
/// createMultipartFile, and only when that returns true
|
||||
func outputFileUrl() -> URL {
|
||||
return FileManager.default.temporaryDirectory.appendingPath(outputFilename)
|
||||
}
|
||||
|
||||
/// Write form fields
|
||||
///
|
||||
/// Returns true if successful, false otherwise
|
||||
private func writeFields(fileHandle: FileHandle) -> Bool {
|
||||
// construct the preamble
|
||||
return writeText(fileHandle: fileHandle, text: "\(fieldsString)--\(Uploader.boundary)\(lineFeed)")
|
||||
}
|
||||
|
||||
/// Write the data bytes from the provided inputStream to the fileHandle
|
||||
///
|
||||
/// Retruns true if successful, false otherwise
|
||||
private func writeFileBytes(fileHandle: FileHandle, inputStream: InputStream, contentDisposition: String, contentType: String) -> Bool {
|
||||
// multipart file header
|
||||
if !(writeText(fileHandle: fileHandle, text: contentDisposition) &&
|
||||
writeText(fileHandle: fileHandle, text: contentType)) {
|
||||
return false
|
||||
}
|
||||
// file bytes
|
||||
inputStream.open()
|
||||
while inputStream.hasBytesAvailable {
|
||||
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
|
||||
defer {
|
||||
buffer.deallocate()
|
||||
}
|
||||
let bytesRead = inputStream.read(buffer, maxLength: bufferSize)
|
||||
if bytesRead < 0 {
|
||||
// Stream error occured
|
||||
os_log("Error reading from file for taskId %@", log: log, type: .error, task.taskId)
|
||||
inputStream.close()
|
||||
return false
|
||||
} else if bytesRead == 0 {
|
||||
inputStream.close()
|
||||
return true
|
||||
}
|
||||
let data = Data.init(bytesNoCopy: buffer, count: bytesRead, deallocator: .none)
|
||||
do {
|
||||
try fileHandle.write(contentsOf: data)
|
||||
} catch {
|
||||
os_log("Error writing to file for taskId %@: %@", log: log, type: .error, task.taskId, error.localizedDescription)
|
||||
inputStream.close()
|
||||
return false
|
||||
}
|
||||
}
|
||||
inputStream.close()
|
||||
return true
|
||||
}
|
||||
|
||||
/// Write text to the [fileHandle] and return true if successful
|
||||
private func writeText(fileHandle: FileHandle, text: String) -> Bool {
|
||||
guard let epilogue = text.data(using: .utf8) else {
|
||||
return false
|
||||
}
|
||||
do {
|
||||
try fileHandle.write(contentsOf: epilogue)
|
||||
} catch {
|
||||
os_log("Error writing to file for taskId %@: %@", log: log, type: .error, task.taskId, error.localizedDescription)
|
||||
return false
|
||||
}
|
||||
totalBytesWritten += Int64(epilogue.count)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
/// Returns the multipart entry for one field name/value pair
|
||||
private func fieldEntry(name: String, value: String) -> String {
|
||||
return "--\(Uploader.boundary)\(lineFeed)\(headerForField(name: name, value: value))\(value)\(lineFeed)"
|
||||
}
|
||||
|
||||
/// Returns the header string for a field
|
||||
///
|
||||
/// The return value is guaranteed to contain only ASCII characters
|
||||
private func headerForField(name: String, value: String) -> String {
|
||||
var header = "Content-Disposition: form-data; name=\"\(browserEncode(name))\""
|
||||
if isJsonString(value) {
|
||||
header = "\(header)\r\n" +
|
||||
"Content-Type: application/json; charset=utf-8"
|
||||
} else if !isPlainAscii(value) {
|
||||
header = "\(header)\r\n" +
|
||||
"Content-Type: text/plain; charset=utf-8\r\n" +
|
||||
"Content-Transfer-Encoding: binary"
|
||||
}
|
||||
return "\(header)\r\n\r\n"
|
||||
}
|
||||
|
||||
|
||||
/// Returns whether [string] is composed entirely of ASCII-compatible characters
|
||||
private func isPlainAscii(_ string: String)-> Bool {
|
||||
let result = asciiOnly.matches(in: string,
|
||||
range: NSMakeRange(0, (string as NSString).length))
|
||||
return !result.isEmpty
|
||||
}
|
||||
|
||||
/// Returns whether [string] is a JSON formatted string
|
||||
private func isJsonString(_ string: String)-> Bool {
|
||||
let result = jsonString.matches(in: string,
|
||||
range: NSMakeRange(0, (string as NSString).length))
|
||||
return !result.isEmpty
|
||||
}
|
||||
|
||||
/// Encode [value] in the same way browsers do
|
||||
private func browserEncode(_ value: String)-> String {
|
||||
// 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.
|
||||
let newlinesReplaced = newlineRegExp.stringByReplacingMatches(
|
||||
in: value,
|
||||
options: [],
|
||||
range: NSMakeRange(0, (value as NSString).length), withTemplate: "%0D%0A")
|
||||
return newlinesReplaced.replacingOccurrences(of: "\"", with: "%22")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
+762
@@ -0,0 +1,762 @@
|
||||
//
|
||||
// UriUtils.swift
|
||||
// background_downloader
|
||||
//
|
||||
// Created by Bram on 1/18/25.
|
||||
//
|
||||
|
||||
import Flutter
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
import MobileCoreServices
|
||||
import PhotosUI
|
||||
import os.log
|
||||
|
||||
|
||||
|
||||
public class UriUtilsMethodCallHelper: NSObject,
|
||||
FlutterPlugin,
|
||||
UIDocumentPickerDelegate,
|
||||
PHPickerViewControllerDelegate {
|
||||
private var flutterResult: FlutterResult?
|
||||
private var persistedUriPermission: Bool = false
|
||||
private var multipleAllowed: Bool = false
|
||||
private var allowVideos = false
|
||||
private var localFileUrls: [String] = [] // for media picker
|
||||
private var accessedSecurityScopedUrls: Set<URL> = Set()
|
||||
|
||||
public static func register(with registrar: FlutterPluginRegistrar) {
|
||||
let channel = FlutterMethodChannel(name: "com.bbflight.background_downloader.uriutils", binaryMessenger: registrar.messenger())
|
||||
let instance = UriUtilsMethodCallHelper()
|
||||
registrar.addMethodCallDelegate(instance, channel: channel)
|
||||
}
|
||||
|
||||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "pickDirectory":
|
||||
pickDirectory(call, result: result)
|
||||
case "pickFiles":
|
||||
pickFiles(call, result: result)
|
||||
case "createDirectory":
|
||||
createDirectory(call, result: result)
|
||||
case "activateUri":
|
||||
activateUri(call, result: result)
|
||||
case "getFileBytes":
|
||||
getFileBytes(call, result: result)
|
||||
case "copyFile":
|
||||
copyFile(call, result: result)
|
||||
case "moveFile":
|
||||
moveFile(call, result: result)
|
||||
case "deleteFile":
|
||||
deleteFile(call, result: result)
|
||||
case "openFile":
|
||||
openFile(call, result: result)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
private func pickDirectory(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let args = call.arguments as? [Any],
|
||||
let persistedUriPermission = args[2] as? Bool else {
|
||||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for pickDirectory", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
self.persistedUriPermission = persistedUriPermission
|
||||
self.flutterResult = result
|
||||
|
||||
var startLocation: URL? = nil
|
||||
|
||||
if let startLocationUriString = args[1] as? String,
|
||||
let startLocationUri = decodeToFileUrl(uriString: startLocationUriString) {
|
||||
startLocation = startLocationUri
|
||||
} else if let startLocationOrdinal = args[0] as? Int,
|
||||
let sharedStorage = SharedStorage(rawValue: startLocationOrdinal) {
|
||||
startLocation = getInitialDirectoryUrl(location: sharedStorage)
|
||||
}
|
||||
|
||||
let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [UTType.folder], asCopy: false)
|
||||
documentPicker.delegate = self
|
||||
documentPicker.allowsMultipleSelection = false
|
||||
|
||||
// Set the initial directory if available
|
||||
if #available(iOS 14.0, *), let startLocation = startLocation {
|
||||
documentPicker.directoryURL = startLocation
|
||||
}
|
||||
|
||||
// Present the document picker
|
||||
if let rootViewController = getRootViewController() {
|
||||
rootViewController.present(documentPicker, animated: true, completion: nil)
|
||||
} else {
|
||||
completeFlutterResult(FlutterError(code: "NO_ROOT_VIEW_CONTROLLER", message: "Could not find root view controller", details: nil))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func pickFiles(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let args = call.arguments as? [Any],
|
||||
let multipleAllowed = args[3] as? Bool,
|
||||
let persistedUriPermission = args[4] as? Bool else {
|
||||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for pickFiles", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
self.persistedUriPermission = persistedUriPermission
|
||||
self.multipleAllowed = multipleAllowed
|
||||
self.flutterResult = result
|
||||
|
||||
var startLocation: URL? = nil
|
||||
|
||||
if let startLocationUriString = args[1] as? String,
|
||||
let startLocationUri = decodeToFileUrl(uriString: startLocationUriString) {
|
||||
startLocation = startLocationUri
|
||||
} else if let startLocationOrdinal = args[0] as? Int,
|
||||
let sharedStorage = SharedStorage(rawValue: startLocationOrdinal) {
|
||||
if sharedStorage == .images || sharedStorage == .video {
|
||||
pickMedia(startLocation: sharedStorage)
|
||||
return
|
||||
}
|
||||
startLocation = getInitialDirectoryUrl(location: sharedStorage)
|
||||
}
|
||||
|
||||
// Convert allowed extensions to UTTypes
|
||||
var allowedContentTypes: [UTType] = []
|
||||
if let allowedExtensions = args[2] as? [String] {
|
||||
for ext in allowedExtensions {
|
||||
if let utType = UTType(filenameExtension: ext) {
|
||||
allowedContentTypes.append(utType)
|
||||
}
|
||||
}
|
||||
}
|
||||
if allowedContentTypes.isEmpty {
|
||||
allowedContentTypes = [UTType.item] // Allow all types if no valid extensions are provided
|
||||
}
|
||||
|
||||
let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: allowedContentTypes, asCopy: false)
|
||||
documentPicker.delegate = self
|
||||
documentPicker.allowsMultipleSelection = multipleAllowed
|
||||
|
||||
// Set the initial directory if available
|
||||
if let startLocation = startLocation {
|
||||
documentPicker.directoryURL = startLocation
|
||||
}
|
||||
|
||||
// Present the document picker
|
||||
if let rootViewController = UIApplication.shared.keyWindow?.rootViewController {
|
||||
rootViewController.present(documentPicker, animated: true, completion: nil)
|
||||
} else {
|
||||
completeFlutterResult(FlutterError(code: "NO_ROOT_VIEW_CONTROLLER", message: "Could not find root view controller", details: nil))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches the photo picker to select one or more media files (images or videos).
|
||||
*
|
||||
* Called only from [pickFiles] where argument
|
||||
* parsing has already happened
|
||||
*
|
||||
* @param startLocation The shared storage location to start the picker in (images or video).
|
||||
*
|
||||
* The result posted back is either a list of URIs as a String, or null (if the user has
|
||||
* cancelled the picker) or an error
|
||||
*/
|
||||
private func pickMedia(startLocation: SharedStorage) {
|
||||
var configuration = PHPickerConfiguration()
|
||||
configuration.filter = startLocation == .images ? .images : .videos
|
||||
configuration.selectionLimit = multipleAllowed ? 0 : 1 // 0 means unlimited
|
||||
let pickerViewController = PHPickerViewController(configuration: configuration)
|
||||
pickerViewController.delegate = self
|
||||
|
||||
if let rootViewController = getRootViewController() {
|
||||
rootViewController.present(pickerViewController, animated: true, completion: nil)
|
||||
} else {
|
||||
completeFlutterResult(FlutterError(code: "NO_ROOT_VIEW_CONTROLLER", message: "Could not find root view controller", details: nil))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Creates a new directory at the specified path within the given parent directory URI.
|
||||
* The parent directory URI must be resolvable to a file:// URI. Supports creating intermediate directories.
|
||||
*
|
||||
* - Parameters:
|
||||
* - call: The Flutter method call. The arguments are expected to be a list
|
||||
* containing: the parent directory URI (String), the new directory name (String),
|
||||
* and a boolean indicating whether to request persisted URI permission.
|
||||
* - result: The Flutter result callback. On success, the URI of the newly
|
||||
* created directory (String) is returned. On failure, a `FlutterError` is returned.
|
||||
*/
|
||||
private func createDirectory(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let args = call.arguments as? [Any],
|
||||
let parentDirectoryUriString = args[0] as? String,
|
||||
let newDirectoryName = args[1] as? String,
|
||||
let persistedUriPermission = args[2] as? Bool,
|
||||
let parentDirectoryUri = decodeToFileUrl(uriString: parentDirectoryUriString)
|
||||
else {
|
||||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for createDirectory", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let newDirectoryURL = parentDirectoryUri.appendingPathComponent(newDirectoryName, isDirectory: true)
|
||||
|
||||
// Ensure we have access to the parent directory before attempting to create a subdirectory.
|
||||
if !parentDirectoryUri.startAccessingSecurityScopedResource() {
|
||||
result(FlutterError(code: "ACCESS_DENIED", message: "Failed to access parent directory: \(parentDirectoryUri)", details: nil))
|
||||
return
|
||||
}
|
||||
accessedSecurityScopedUrls.insert(parentDirectoryUri)
|
||||
|
||||
// Create the directory
|
||||
try FileManager.default.createDirectory(at: newDirectoryURL, withIntermediateDirectories: true, attributes: nil)
|
||||
|
||||
// If persistence is requested, we will also need to stop accessing and deallocate the URL when done.
|
||||
if persistedUriPermission {
|
||||
accessedSecurityScopedUrls.insert(newDirectoryURL)
|
||||
// Return the persisted URI (bookmark)
|
||||
let bookmarkUri = try createBookmarkUriFromUrl(fileURL: newDirectoryURL)
|
||||
result(bookmarkUri.absoluteString)
|
||||
} else {
|
||||
// Return the regular file URI
|
||||
result(newDirectoryURL.absoluteString)
|
||||
}
|
||||
|
||||
} catch {
|
||||
result(FlutterError(code: "CREATE_DIRECTORY_FAILED", message: "Failed to create directory: \(error.localizedDescription)", details: nil))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activates a previously accessed directory or file (represented by a URI string)
|
||||
* by calling `startAccessingSecurityScopedResource`. This is necessary on iOS
|
||||
* to regain access to resources outside the app's sandbox that were previously
|
||||
* granted access (e.g., through a document picker). Also decodes `urlbookmark://`
|
||||
* and `media://` URIs to `file://` URIs.
|
||||
*
|
||||
* - Parameters:
|
||||
* - call: The Flutter method call. The argument is expected to be the URI string.
|
||||
* - result: The Flutter result callback. On success, the (potentially decoded)
|
||||
* `file://` URI string is returned. On failure (e.g., invalid URI, access denied),
|
||||
* a `FlutterError` is returned. If the URI is decoded, it is also added to
|
||||
* `accessedSecurityScopedUrls`
|
||||
*/
|
||||
private func activateUri(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let uriString = call.arguments as? String,
|
||||
let uri = decodeToFileUrl(uriString: uriString)
|
||||
else {
|
||||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for activateUri", details: nil))
|
||||
return
|
||||
}
|
||||
// Start accessing the security-scoped resource.
|
||||
if !uri.startAccessingSecurityScopedResource() {
|
||||
completeFlutterResult(FlutterError(code: "ACCESS_DENIED", message: "activateUri failed to access security-scoped resource: \(uri)", details: nil))
|
||||
return
|
||||
}
|
||||
accessedSecurityScopedUrls.insert(uri)
|
||||
result(uri.absoluteString)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the file data (bytes) for a given URI string.
|
||||
*
|
||||
* - Parameters:
|
||||
* - call: The Flutter method call containing the arguments. The argument is
|
||||
* expected to be the URI string. This string will be decoded using `decodeToFileUrl`.
|
||||
* - result: The Flutter result callback to be invoked with the resul---t. On success
|
||||
* the file data as a `FlutterStandardTypedData` is returned. On failure,
|
||||
* a `FlutterError` is returned.
|
||||
*/
|
||||
private func getFileBytes(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let uriString = call.arguments as? String,
|
||||
let fileUrl = decodeToFileUrl(uriString: uriString) else {
|
||||
result(FlutterError(code: "INVALID_URI", message: "Invalid or unresolvable URI: \(call.arguments)", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
// If this is a bookmark URI, we need to start accessing the security-scoped resource.
|
||||
if uriString.starts(with: "urlbookmark://") {
|
||||
if !fileUrl.startAccessingSecurityScopedResource() {
|
||||
result(FlutterError(code: "ACCESS_DENIED", message: "Failed to access security-scoped resource: \(fileUrl)", details: nil))
|
||||
return
|
||||
}
|
||||
accessedSecurityScopedUrls.insert(fileUrl)
|
||||
}
|
||||
|
||||
do {
|
||||
let fileData = try Data(contentsOf: fileUrl)
|
||||
result(FlutterStandardTypedData(bytes: fileData))
|
||||
} catch {
|
||||
result(FlutterError(code: "FILE_READ_ERROR", message: "Failed to read file data: \(error.localizedDescription)", details: nil))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the file at the given source URI to the destination URI.
|
||||
* Both the source and destination URIs must be resolvable to file:// URIs.
|
||||
*
|
||||
* - Parameters:
|
||||
* - call: The Flutter method call containing the arguments. The arguments
|
||||
* are expected to be a list containing two strings: the source URI and
|
||||
* the destination URI.
|
||||
* - result: The Flutter result callback to be invoked with the result
|
||||
* of the copy operation. On success, the destination URI string is
|
||||
* returned. On failure, a `FlutterError` is returned.
|
||||
*/
|
||||
private func copyFile(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let args = call.arguments as? [Any],
|
||||
let sourceUriString = args[0] as? String,
|
||||
let destinationUriString = args[1] as? String,
|
||||
let sourceUrl = decodeToFileUrl(uriString: sourceUriString), // Decode to file URL
|
||||
let destinationUrl = decodeToFileUrl(uriString: destinationUriString) // Decode to file URL
|
||||
else {
|
||||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for copyFile", details: nil))
|
||||
return
|
||||
}
|
||||
do {
|
||||
// Ensure destination directory exists
|
||||
let destinationDirectory = destinationUrl.deletingLastPathComponent()
|
||||
if !FileManager.default.fileExists(atPath: destinationDirectory.path) {
|
||||
try FileManager.default.createDirectory(at: destinationDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||
}
|
||||
try FileManager.default.copyItem(at: sourceUrl, to: destinationUrl)
|
||||
result(destinationUrl.absoluteString)
|
||||
} catch {
|
||||
result(FlutterError(code: "COPY_FAILED", message: "Failed to copy file: \(error.localizedDescription)", details: nil))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the file at the given source URI to the destination URI.
|
||||
* Both the source and destination URIs must be resolvable to file:// URIs.
|
||||
*
|
||||
* - Parameters:
|
||||
* - call: The Flutter method call containing the arguments. The arguments
|
||||
* are expected to be a list containing two strings: the source URI and
|
||||
* the destination URI.
|
||||
* - result: The Flutter result callback to be invoked with the result
|
||||
* of the move operation. On success, the destination URI string is
|
||||
* returned. On failure, a `FlutterError` is returned.
|
||||
*/
|
||||
private func moveFile(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let args = call.arguments as? [Any],
|
||||
let sourceUriString = args[0] as? String,
|
||||
let destinationUriString = args[1] as? String,
|
||||
let sourceUrl = decodeToFileUrl(uriString: sourceUriString), // Decode!
|
||||
let destinationUrl = decodeToFileUrl(uriString: destinationUriString) // Decode!
|
||||
else {
|
||||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for moveFile", details: nil))
|
||||
return
|
||||
}
|
||||
do {
|
||||
// Ensure destination directory exists
|
||||
let destinationDirectory = destinationUrl.deletingLastPathComponent()
|
||||
if !FileManager.default.fileExists(atPath: destinationDirectory.path) {
|
||||
try FileManager.default.createDirectory(at: destinationDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||
}
|
||||
try FileManager.default.moveItem(at: sourceUrl, to: destinationUrl)
|
||||
result(destinationUrl.absoluteString)
|
||||
} catch {
|
||||
result(FlutterError(code: "MOVE_FAILED", message: "Failed to move file: \(error.localizedDescription)", details: nil))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the file at the given URI. The URI must be resolvable to a file:// URI.
|
||||
*
|
||||
* - Parameters:
|
||||
* - call: The Flutter method call containing the arguments. The argument
|
||||
* is expected to be a string representing the file URI.
|
||||
* - result: The Flutter result callback. `true` is returned on success,
|
||||
* and a `FlutterError` is returned on failure.
|
||||
*/
|
||||
private func deleteFile(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let uriString = call.arguments as? String,
|
||||
let fileUrl = decodeToFileUrl(uriString: uriString),
|
||||
fileUrl.scheme == "file" else {
|
||||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid or non-file URI provided for deleteFile", details: nil))
|
||||
return
|
||||
}
|
||||
do {
|
||||
try FileManager.default.removeItem(at: fileUrl)
|
||||
result(true)
|
||||
} catch {
|
||||
result(FlutterError(code: "DELETE_FILE_FAILED", message: "Failed to delete file: \(error.localizedDescription)", details: nil))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the file at the given URI using the system's default application for the file type.
|
||||
*
|
||||
* - Parameters:
|
||||
* - call: The Flutter method call containing the arguments. The arguments are
|
||||
* expected to be a list where the first element is the URI string, and the
|
||||
* optional second element is the MIME type (as a String).
|
||||
* - result: The Flutter result callback. `true` is returned on successful launch,
|
||||
* and a `FlutterError` is returned on failure.
|
||||
*/
|
||||
private func openFile(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let args = call.arguments as? [Any],
|
||||
let uriString = args[0] as? String,
|
||||
let mimeType = args[1] as? String?,
|
||||
let fileUrl = decodeToFileUrl(uriString: uriString)
|
||||
else {
|
||||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for openFile", details: nil))
|
||||
return
|
||||
}
|
||||
let filePath = fileUrl.path
|
||||
if !FileManager.default.fileExists(atPath: fileUrl.path) {
|
||||
os_log("File does not exist at %@", log: log, type: .info, filePath)
|
||||
result(FlutterError(code: "FILE_NOT_FOUND", message: "File does not exist at \(filePath)", details: nil))
|
||||
return
|
||||
}
|
||||
result(doOpenFile(filePath: filePath, mimeType: mimeType != nil ? mimeType : getMimeType(fromFilename: filePath)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the flutter result callback and destroy the result object
|
||||
*/
|
||||
private func completeFlutterResult(_ result: Any?) {
|
||||
self.flutterResult?(result)
|
||||
self.flutterResult = nil
|
||||
}
|
||||
|
||||
// MARK: - UIDocumentPickerDelegate
|
||||
|
||||
public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||
|
||||
if multipleAllowed {
|
||||
var resultUrls: [String] = []
|
||||
|
||||
for url in urls {
|
||||
// Start accessing the security-scoped resource.
|
||||
if !url.startAccessingSecurityScopedResource() {
|
||||
// Handle access error (e.g., by skipping this URL and logging an error message)
|
||||
os_log("Failed to access security-scoped resource for %@", log: log, type: .info, url.absoluteString)
|
||||
continue
|
||||
}
|
||||
accessedSecurityScopedUrls.insert(url)
|
||||
|
||||
let pickedUrl: URL
|
||||
if persistedUriPermission {
|
||||
do {
|
||||
pickedUrl = try createBookmarkUriFromUrl(fileURL: url)
|
||||
} catch {
|
||||
completeFlutterResult(FlutterError(code: "CREATE_PERSISTED_URI_FAILED", message: "Failed to create persisted URI: \(error.localizedDescription)", details: nil))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
pickedUrl = url
|
||||
}
|
||||
|
||||
resultUrls.append(pickedUrl.absoluteString)
|
||||
}
|
||||
|
||||
completeFlutterResult(resultUrls)
|
||||
} else {
|
||||
// Single selection
|
||||
guard let url = urls.first else {
|
||||
completeFlutterResult(nil) // User cancelled
|
||||
return
|
||||
}
|
||||
|
||||
// Start accessing the security-scoped resource.
|
||||
if !url.startAccessingSecurityScopedResource() {
|
||||
completeFlutterResult(FlutterError(code: "ACCESS_DENIED", message: "Failed to access security-scoped resource: \(url)", details: nil))
|
||||
return
|
||||
}
|
||||
accessedSecurityScopedUrls.insert(url)
|
||||
|
||||
let pickedUrl: URL
|
||||
if persistedUriPermission {
|
||||
do {
|
||||
pickedUrl = try createBookmarkUriFromUrl(fileURL: url)
|
||||
} catch {
|
||||
completeFlutterResult(FlutterError(code: "CREATE_PERSISTED_URI_FAILED", message: "Failed to create persisted URI: \(error.localizedDescription)", details: nil))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
pickedUrl = url
|
||||
}
|
||||
completeFlutterResult(pickedUrl.absoluteString)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
||||
completeFlutterResult(nil)
|
||||
}
|
||||
|
||||
|
||||
// MARK: PHickerViewControllerelegate
|
||||
|
||||
public func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
guard !results.isEmpty else {
|
||||
completeFlutterResult(nil)
|
||||
return
|
||||
}
|
||||
let group = DispatchGroup()
|
||||
for result in results {
|
||||
group.enter()
|
||||
let itemProvider = result.itemProvider
|
||||
|
||||
// Determine the preferred order based on what is allowed
|
||||
var typeIdentifiers: [String] = []
|
||||
if allowVideos {
|
||||
typeIdentifiers.append(UTType.movie.identifier)
|
||||
}
|
||||
typeIdentifiers.append(UTType.image.identifier)
|
||||
|
||||
var handled = false
|
||||
for typeIdentifier in typeIdentifiers {
|
||||
if itemProvider.hasRepresentationConforming(toTypeIdentifier: typeIdentifier) {
|
||||
handleLoadedFile(for: itemProvider, typeIdentifier: typeIdentifier, group: group)
|
||||
handled = true
|
||||
break // Break after handling the first matching type
|
||||
}
|
||||
}
|
||||
|
||||
// Leave the group if no suitable representation was found
|
||||
if !handled {
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
// after all files have been copied, post the result
|
||||
group.notify(queue: .main) {
|
||||
picker.dismiss(animated: true)
|
||||
if self.localFileUrls.isEmpty {
|
||||
self.completeFlutterResult(nil)
|
||||
} else {
|
||||
self.completeFlutterResult(self.localFileUrls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles the loading of a file representation from an `NSItemProvider`.
|
||||
///
|
||||
/// This function attempts to load a file of the specified `typeIdentifier` from the given `itemProvider`.
|
||||
/// If successful, it copies the file to the app's local storage and appends the local file URL to the `localFileUrls` array.
|
||||
/// This function is designed to be used within the `PHPickerViewControllerDelegate`'s `didFinishPicking` method to process selected assets.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - itemProvider: The `NSItemProvider` representing the asset to load.
|
||||
/// - typeIdentifier: The UTI (Uniform Type Identifier) representing the desired file type (e.g., `public.image`, `public.movie`).
|
||||
/// - group: The `DispatchGroup` to which the asynchronous file loading operation belongs. `group.leave()` is called when the operation is completed (either successfully or with an error).
|
||||
///
|
||||
/// - Important: This function uses an escaping closure to handle the asynchronous loading of the file.
|
||||
/// The `localFileUrls` array is a class property and is modified within the escaping closure.
|
||||
private func handleLoadedFile(for itemProvider: NSItemProvider, typeIdentifier: String, group: DispatchGroup) {
|
||||
itemProvider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { [weak self] url, error in
|
||||
guard let self = self, let url = url, error == nil else {
|
||||
group.leave()
|
||||
return
|
||||
}
|
||||
let localUrl = self.copyFileToLocalStorage(url: url)
|
||||
if let localUrl = localUrl {
|
||||
self.localFileUrls.append(localUrl.absoluteString)
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies a file from the provided URL into the app's temporary storage directory, returning the new URL relative to the storage root. This URL will have the "support" scheme and
|
||||
/// itspath is the last pathsegment of the file URL. It will be converted to a proper file:// URL using `decodeToFileUrl`
|
||||
///
|
||||
/// This function creates a unique filename for the copied file to avoid collisions. The file is stored in a location that is:
|
||||
/// 1. **Private to the application:** Other apps cannot access this directory.
|
||||
/// 2. **Temporary:** The system may delete files in this directory when the app is not running to free up space. Your app should be prepared to recreate these files as needed.
|
||||
/// 3. **Not backed up:** Files in this directory are not backed up to iCloud or other backup services.
|
||||
/// 4. **Persistent across app launches:** The relative file paths are consistent between app launches.
|
||||
///
|
||||
/// - Parameter url: The source URL of the file to copy. This should be a file URL (e.g., obtained from a `PHPickerResult`).
|
||||
/// - Returns: The relative path (as a String) to the copied file within the temporary storage directory if the copy is successful, otherwise `nil`.
|
||||
/// You can reconstruct the full URL using `constructPersistentFileURL(for:)`.
|
||||
private func copyFileToLocalStorage(url: URL) -> URL? {
|
||||
let fileManager = FileManager.default
|
||||
|
||||
// Use the Application Support directory, which is private to the app but persistent.
|
||||
// Create a custom subdirectory for temporary files.
|
||||
guard let storageDirectory = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?.appendingPathComponent("com.bbflight.downloader.media", isDirectory: true) else {
|
||||
completeFlutterResult(FlutterError(code: "PICK_FAILED", message: "Could not find Application Support directory", details: nil))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure the storage directory exists
|
||||
do {
|
||||
try fileManager.createDirectory(at: storageDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||
} catch {
|
||||
completeFlutterResult(FlutterError(code: "PICK_FAILED", message: "Error creating storage directory: \(error)", details: nil))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create a unique filename
|
||||
let uniqueFilename = "\(UUID().uuidString).\(url.pathExtension)"
|
||||
let destinationURL = storageDirectory.appendingPathComponent(uniqueFilename)
|
||||
|
||||
do {
|
||||
// If a file with the same name already exists, remove it. This should not normally happen because of the UUID, but it's good practice.
|
||||
if fileManager.fileExists(atPath: destinationURL.path) {
|
||||
try fileManager.removeItem(at: destinationURL)
|
||||
}
|
||||
|
||||
// Copy the file from source to destination, and return the media URI to this location
|
||||
try fileManager.copyItem(at: url, to: destinationURL)
|
||||
return URL(string: "media://support/\(destinationURL.lastPathComponent)")
|
||||
} catch {
|
||||
completeFlutterResult(FlutterError(code: "PICK_FAILED", message: "Error copying file: \(error)", details: nil))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Helper Functions
|
||||
|
||||
private func getInitialDirectoryUrl(location: SharedStorage) -> URL? {
|
||||
switch location {
|
||||
case SharedStorage.downloads:
|
||||
return FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
|
||||
case SharedStorage.images:
|
||||
return FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first
|
||||
case SharedStorage.video:
|
||||
return FileManager.default.urls(for: .moviesDirectory, in: .userDomainMask).first
|
||||
case SharedStorage.audio:
|
||||
return FileManager.default.urls(for: .musicDirectory, in: .userDomainMask).first
|
||||
case SharedStorage.files:
|
||||
return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Stop accessing security-scoped resources when appropriate.
|
||||
deinit {
|
||||
// Iterate through all stored URLs and stop accessing the security-scoped resources.
|
||||
for url in accessedSecurityScopedUrls {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Packs `filename` and `uri` into a single String
|
||||
///
|
||||
/// use `unpack` to retrieve the filename and uri from the packed String
|
||||
func pack(filename: String, uri: URL) -> String {
|
||||
return ":::\(filename)::::::\(uri.absoluteString):::"
|
||||
}
|
||||
|
||||
/// Unpacks `packedString` into a (filename, uri) tuple. If this is not a packed
|
||||
/// string, returns the original `packedString` as (filename, nil) or,
|
||||
/// if it is a Uri as (nil, the uri)
|
||||
func unpack(packedString: String) -> (filename: String?, uri: URL?) {
|
||||
let regex = try! NSRegularExpression(pattern: ":::([\\s\\S]*?)::::::([\\s\\S]*?):::")
|
||||
let range = NSRange(packedString.startIndex..<packedString.endIndex, in: packedString)
|
||||
|
||||
if let match = regex.firstMatch(in: packedString, range: range) {
|
||||
let filenameRange = match.range(at: 1)
|
||||
let uriStringRange = match.range(at: 2)
|
||||
|
||||
if filenameRange.location != NSNotFound && uriStringRange.location != NSNotFound {
|
||||
let filename = String(packedString[Range(filenameRange, in: packedString)!])
|
||||
let uriString = String(packedString[Range(uriStringRange, in: packedString)!])
|
||||
|
||||
if let uri = URL(string: uriString), uri.scheme != nil {
|
||||
return (filename: filename, uri: uri)
|
||||
} else {
|
||||
return (filename: filename, uri: nil)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if let uri = URL(string: packedString), uri.scheme != nil {
|
||||
return (filename: nil, uri: uri)
|
||||
}
|
||||
}
|
||||
return (filename: packedString, uri: nil)
|
||||
}
|
||||
|
||||
/// Returns the Uri represented by `maybePacked`, or nil if the String is not a
|
||||
/// valid Uri or packed Uri string.
|
||||
///
|
||||
/// `maybePacked` should be a full Uri string, or a packed String containing
|
||||
/// a Uri (see `pack`)
|
||||
func uriFromStringValue(maybePacked: String) -> URL? {
|
||||
let unpacked = unpack(packedString: maybePacked)
|
||||
return unpacked.uri
|
||||
}
|
||||
|
||||
/// Returns true if `maybePacked` is a valid Uri or packed Uri string.
|
||||
///
|
||||
/// `maybePacked` should be a full Uri string, or a packed String containing
|
||||
/// a Uri (see `pack`)
|
||||
func containsUri(maybePacked: String) -> Bool {
|
||||
return uriFromStringValue(maybePacked: maybePacked) != nil
|
||||
}
|
||||
|
||||
/// Creates a urlboomark://authority/ URL containing encoded data representing the bookmark for this URL, which
|
||||
/// can then be used later
|
||||
private func createBookmarkUriFromUrl(fileURL: URL) throws -> URL {
|
||||
let bookmarkData = try fileURL.bookmarkData(options: .suitableForBookmarkFile, includingResourceValuesForKeys: nil, relativeTo: nil)
|
||||
let bookmarkDataBase64 = bookmarkData.base64EncodedString()
|
||||
let bookmarkUriString = "urlbookmark://authority/\(bookmarkDataBase64)"
|
||||
guard let bookmarkUri = URL(string: bookmarkUriString) else {
|
||||
throw NSError(domain: "UriUtilsMethodCallHelper", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to create bookmark URI from '\(bookmarkUriString)'"])
|
||||
}
|
||||
return bookmarkUri
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a URI parameter on iOS.
|
||||
*
|
||||
* If the URI string starts with `urlbookmark://authority/`, it decodes the base64 encoded bookmark data to a file URL
|
||||
* If the URI string starts with `media://support/` it will convert it to a file URL relateive to the applicationSupport directory
|
||||
* Otherwise, it returns the original URI as a URL.
|
||||
*
|
||||
* - Parameter uriString: The URI string to decode.
|
||||
* - Returns: The decoded `URL` or `nil` if the URI string is invalid or the bookmark data cannot be resolved.
|
||||
*/
|
||||
private func decodeToFileUrl(uriString: String) -> URL? {
|
||||
guard let uri = URL(string: uriString) else { return nil }
|
||||
if uri.scheme == "urlbookmark" {
|
||||
// Decode base64 bookmark data
|
||||
let base64String = String(uri.path.dropFirst())
|
||||
guard let bookmarkData = Data(base64Encoded: base64String) else {
|
||||
return nil
|
||||
}
|
||||
do {
|
||||
var isStale = false
|
||||
let resolvedUrl = try URL(resolvingBookmarkData: bookmarkData, options: .withoutUI, relativeTo: nil, bookmarkDataIsStale: &isStale)
|
||||
|
||||
if isStale {
|
||||
os_log("Warning: Bookmark data is stale for %@", log: log, type: .info, resolvedUrl.absoluteString)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Access to resolved URLs that are not persisted, is started when they are actually used
|
||||
// (i.e. when creating a subdirectory in it, or picking it)
|
||||
// And stopped in the deinit
|
||||
return resolvedUrl
|
||||
} catch {
|
||||
os_log("Error resolving bookmark data: %@", log: log, type: .info, error.localizedDescription)
|
||||
return nil
|
||||
}
|
||||
} else if uri.scheme == "media" {
|
||||
guard let storageDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?.appendingPathComponent("com.bbflight.downloader.media", isDirectory: true) else {
|
||||
os_log("Could not determine application support directory - required to decode support:// scheme URL", log: log, type: .error)
|
||||
return nil
|
||||
}
|
||||
return storageDirectory.appendingPathComponent(uri.lastPathComponent, isDirectory: false)
|
||||
} else {
|
||||
// Regular file URL
|
||||
return uri
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Returns a file URI, or nil if not possible.
|
||||
|
||||
Decodes a bookmark URI or a media scheme uri
|
||||
*/
|
||||
func decodeToFileUrl(uri: URL) -> URL? {
|
||||
return uri.scheme == "file" ? uri : decodeToFileUrl(uriString: uri.absoluteString)
|
||||
}
|
||||
+628
@@ -0,0 +1,628 @@
|
||||
//
|
||||
// UrlSessionDelegate.swift
|
||||
// background_downloader
|
||||
//
|
||||
// Created by Bram on 11/12/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
|
||||
public class UrlSessionDelegate : NSObject, URLSessionDelegate, URLSessionDownloadDelegate, URLSessionDataDelegate {
|
||||
|
||||
static let instance = UrlSessionDelegate()
|
||||
static var urlSession: URLSession?
|
||||
public static var sessionIdentifier = "com.bbflight.background_downloader.Downloader"
|
||||
public static var backgroundCompletionHandler: (() -> Void)?
|
||||
|
||||
//MARK: URLSessionTaskDelegate
|
||||
|
||||
/// Called before the task starts, and may continue, cancel or modify the original request, based on native callbacks in the [TaskOptions] of the task
|
||||
public func urlSession(_ session: URLSession, task: URLSessionTask, willBeginDelayedRequest request: URLRequest) async -> (URLSession.DelayedRequestDisposition, URLRequest?) {
|
||||
guard let bgdTask = getTaskFrom(urlSessionTask: task)
|
||||
else {
|
||||
return (.continueLoading, nil)
|
||||
}
|
||||
// check & process beforeTaskStartCallback and cancel the task if returned value is not nil
|
||||
if bgdTask.options?.hasBeforeStartCallback() == true {
|
||||
if let statusUpdate = await invokeBeforeTaskStartCallback(task: bgdTask) {
|
||||
os_log("TaskId %@ interrupted by beforeTaskStart callback", log: log, type: .info, bgdTask.taskId)
|
||||
BDPlugin.propertyLock.withLock( {
|
||||
BDPlugin.taskIdsProgrammaticallyCanceledBeforeStart.insert(bgdTask.taskId)
|
||||
})
|
||||
processStatusUpdate(task: bgdTask,
|
||||
status: statusUpdate.taskStatus,
|
||||
taskException: statusUpdate.exception,
|
||||
responseBody: statusUpdate.responseBody,
|
||||
responseHeaders: statusUpdate.responseHeaders,
|
||||
responseStatusCode: statusUpdate.responseStatusCode)
|
||||
return (.cancel, nil)
|
||||
}
|
||||
}
|
||||
// check & process onStartCalback and onAuthCallback
|
||||
guard bgdTask.options?.hasOnStartCallback() == true || bgdTask.options?.auth?.hasOnAuthCallback() == true
|
||||
else {
|
||||
return (.continueLoading, nil)
|
||||
}
|
||||
let (newTask, taskWasModified) = await getModifiedTask(task: bgdTask) // processes both onStart and onAuth
|
||||
if !taskWasModified {
|
||||
return (.continueLoading, nil)
|
||||
}
|
||||
// modify the request, copying the unmodified data from the original request
|
||||
guard let url = validateUrl(newTask)
|
||||
else {
|
||||
os_log("Invalid url in modified task", log: log, type: .info)
|
||||
return (.continueLoading, nil)
|
||||
}
|
||||
var newRequest = URLRequest(url: url)
|
||||
// all original request headers are copied, then the newTask headers are set:
|
||||
// you can change or add a header, but not remove one
|
||||
if request.allHTTPHeaderFields != nil {
|
||||
for (key, value) in request.allHTTPHeaderFields! {
|
||||
newRequest.setValue(value, forHTTPHeaderField: key)
|
||||
}}
|
||||
for (key, value) in newTask.headers {
|
||||
newRequest.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
newRequest.httpMethod = request.httpMethod
|
||||
newRequest.httpBody = request.httpBody
|
||||
newRequest.allowsCellularAccess = request.allowsCellularAccess
|
||||
return (URLSession.DelayedRequestDisposition.useNewRequest, newRequest)
|
||||
}
|
||||
|
||||
/// Handle task completion
|
||||
///
|
||||
/// DownloadTasks handle task completion in the :didFinishDownloadingTo function, so for download tasks
|
||||
/// we only process status updates if there was an error.
|
||||
/// For other tasks we handle error, or send .complete
|
||||
public func urlSession(
|
||||
_ session: URLSession,
|
||||
task: URLSessionTask,
|
||||
didCompleteWithError error: Error?
|
||||
) {
|
||||
guard let bgdTask = getTaskFrom(urlSessionTask: task) else { // refers to "our" task, not a URLSessionTask
|
||||
os_log("Could not find task related to urlSessionTask %d", log: log, type: .error, task.taskIdentifier)
|
||||
return
|
||||
}
|
||||
if BDPlugin.propertyLock.withLock( {
|
||||
BDPlugin.taskIdsProgrammaticallyCanceledBeforeStart.remove(bgdTask.taskId) != nil
|
||||
}) {
|
||||
// task was canceled before start and the
|
||||
// status update already generated, so simply return without any further processing
|
||||
return
|
||||
}
|
||||
let multipartUploader = BDPlugin.propertyLock.withLock {
|
||||
BDPlugin.uploaderForUrlSessionTaskIdentifier.removeValue(forKey: task.taskIdentifier)
|
||||
}
|
||||
if let uploader = multipartUploader {
|
||||
try? FileManager.default.removeItem(at: uploader.outputFileUrl())
|
||||
}
|
||||
let responseStatusCode = (task.response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
let responseStatusDescription = HTTPURLResponse.localizedString(forStatusCode: responseStatusCode)
|
||||
let responseHeaders = (task.response as? HTTPURLResponse)?.allHeaderFields
|
||||
let notificationConfig = getNotificationConfigFrom(urlSessionTask: task)
|
||||
if let tempUploadUrl = BDPlugin.propertyLock.withLock({ BDPlugin.tasksWithTempUploadFile.removeValue(forKey: bgdTask.taskId) }) {
|
||||
try? FileManager.default.removeItem(at: tempUploadUrl)
|
||||
}
|
||||
if BDPlugin.holdingQueue != nil {
|
||||
_Concurrency.Task {
|
||||
await BDPlugin.holdingQueue?.taskFinished(bgdTask)
|
||||
}
|
||||
}
|
||||
let responseBody = getResponseBody(taskId: bgdTask.taskId)
|
||||
let taskWasProgramaticallyCanceledAfterStart = BDPlugin.propertyLock.withLock( {
|
||||
BDPlugin.taskIdsProgrammaticallyCanceledAfterStart.remove(bgdTask.taskId) != nil
|
||||
})
|
||||
guard error == nil else {
|
||||
var notificationType = taskWasProgramaticallyCanceledAfterStart ? nil : NotificationType.error
|
||||
// handle the error if this task wasn't programatically cancelled (in which
|
||||
// case the error has been handled already)
|
||||
if !taskWasProgramaticallyCanceledAfterStart {
|
||||
// check if we have resume data, and if so, process it
|
||||
let userInfo = (error! as NSError).userInfo
|
||||
let resumeData = userInfo[NSURLSessionDownloadTaskResumeData] as? Data
|
||||
let canResume = resumeData != nil && processResumeData(task: bgdTask, resumeData: resumeData!)
|
||||
// check different error codes
|
||||
if (error! as NSError).code == NSURLErrorTimedOut {
|
||||
os_log("Task with id %@ timed out", log: log, type: .info, bgdTask.taskId)
|
||||
processStatusUpdate(task: bgdTask, status: .failed, taskException: TaskException(type: .connection, httpResponseCode: -1, description: error!.localizedDescription))
|
||||
// replace the 'running' notification, unless the task will be retried
|
||||
notificationType = bgdTask.retriesRemaining > 0 ? nil : .error
|
||||
}
|
||||
else if (error! as NSError).code == NSURLErrorCancelled {
|
||||
// cancelled with resumedata implies 'pause'
|
||||
if canResume {
|
||||
os_log("Paused task with id %@", log: log, type: .info, bgdTask.taskId)
|
||||
processStatusUpdate(task: bgdTask, status: .paused)
|
||||
if isDownloadTask(task: bgdTask) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
updateNotification(task: bgdTask, notificationType: .paused, notificationConfig: notificationConfig)
|
||||
}
|
||||
}
|
||||
BDPlugin.propertyLock.withLock({
|
||||
_ = BDPlugin.progressInfo.removeValue(forKey: bgdTask.taskId) // ensure .running update on resume
|
||||
BDPlugin.initialResponseDataProcessed.remove(bgdTask.taskId)
|
||||
})
|
||||
return
|
||||
}
|
||||
// cancelled without resumedata implies 'cancel'
|
||||
let isReEnqueuing = BDPlugin.propertyLock.withLock {
|
||||
BDPlugin.tasksToReEnqueue.contains(bgdTask)
|
||||
}
|
||||
os_log("Canceled task with id %@", log: log, type: .info, bgdTask.taskId)
|
||||
processStatusUpdate(task: bgdTask, status: .canceled)
|
||||
notificationType = isReEnqueuing ? nil : .canceled
|
||||
}
|
||||
else {
|
||||
os_log("Error for taskId %@: %@", log: log, type: .error, bgdTask.taskId, error!.localizedDescription)
|
||||
processStatusUpdate(task: bgdTask, status: .failed, taskException: TaskException(type: .general, httpResponseCode: -1, description: error!.localizedDescription))
|
||||
}
|
||||
}
|
||||
if isDownloadTask(task: bgdTask) && notificationType != nil {
|
||||
updateNotification(task: bgdTask, notificationType: notificationType!, notificationConfig: notificationConfig)
|
||||
}
|
||||
return
|
||||
}
|
||||
// there was no error
|
||||
os_log("Finished task with id %@", log: log, type: .info, bgdTask.taskId)
|
||||
// if this is an upload task, send final TaskStatus (based on HTTP status code).
|
||||
// for download tasks, this is done in urlSession(downloadTask:, didFinishDownloadingTo:)
|
||||
if isUploadTask(task: bgdTask) {
|
||||
let taskException = TaskException(type: .httpResponse, httpResponseCode: responseStatusCode, description: responseStatusDescription)
|
||||
let finalStatus = (200...206).contains(responseStatusCode)
|
||||
? TaskStatus.complete
|
||||
: responseStatusCode == 404
|
||||
? TaskStatus.notFound
|
||||
: TaskStatus.failed
|
||||
let notificationType = finalStatus == .complete ? NotificationType.complete : NotificationType.error
|
||||
updateNotification(task: bgdTask, notificationType: notificationType, notificationConfig: notificationConfig)
|
||||
processStatusUpdate(task: bgdTask, status: finalStatus, taskException: taskException, responseBody: responseBody, responseHeaders: responseHeaders, responseStatusCode: responseStatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
//MARK: URLSessionDownloadTaskDelegate
|
||||
|
||||
|
||||
|
||||
/// Process taskdelegate progress update for download task
|
||||
///
|
||||
/// If the task requires progress updates, provides these at a reasonable interval
|
||||
/// If this is the first update for this file, also emits a 'running' status update
|
||||
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
|
||||
// task is var because the filename can be changed on the first 'didWriteData' call
|
||||
guard var task = getTaskFrom(urlSessionTask: downloadTask) else { return }
|
||||
let progressInfo = BDPlugin.propertyLock.withLock({
|
||||
BDPlugin.progressInfo[task.taskId]
|
||||
})
|
||||
if progressInfo == nil {
|
||||
// first 'didWriteData' call, so send 'running' status update and show notification
|
||||
os_log("Starting/resuming taskId %@", log: log, type: .info, task.taskId)
|
||||
processStatusUpdate(task: task, status: TaskStatus.running)
|
||||
processProgressUpdate(task: task, progress: 0.0)
|
||||
BDPlugin.propertyLock.withLock({
|
||||
BDPlugin.progressInfo[task.taskId] = (lastProgressUpdateTime: 0, lastProgressValue: 0.0, lastTotalBytesDone: 0, lastNetworkSpeed: -1.0)
|
||||
})
|
||||
let notificationConfig = getNotificationConfigFrom(urlSessionTask: downloadTask)
|
||||
if (notificationConfig != nil) {
|
||||
updateNotification(task: task, notificationType: .running, notificationConfig: notificationConfig)
|
||||
}
|
||||
}
|
||||
if !BDPlugin.propertyLock.withLock({ BDPlugin.initialResponseDataProcessed.contains(task.taskId) }) {
|
||||
// response can be nil in cases when progressInfo is nil due to app resuming in foreground
|
||||
if let response = downloadTask.response as? HTTPURLResponse {
|
||||
BDPlugin.propertyLock.withLock( {
|
||||
BDPlugin.initialResponseDataProcessed.insert(task.taskId)
|
||||
})
|
||||
// get suggested filename if needed
|
||||
let (filename, uri) = unpack(packedString: task.filename)
|
||||
if filename == "?" {
|
||||
if (uri == nil) {
|
||||
let newTask = taskWithSuggestedFilenameFromResponseHeaders(task: task, responseHeaders: response.allHeaderFields)
|
||||
if newTask.filename != task.filename {
|
||||
storeModifiedTask(task: newTask)
|
||||
task = newTask
|
||||
}
|
||||
} else {
|
||||
let suggestedFilename = suggestFilename(responseHeaders: response.allHeaderFields, urlString: task.url)
|
||||
let packed = pack(filename: suggestedFilename.isEmpty ? "unknown" : suggestedFilename, uri: uri!)
|
||||
let newTask = task.copyWith(filename: packed)
|
||||
storeModifiedTask(task: newTask)
|
||||
task = newTask
|
||||
}
|
||||
}
|
||||
extractContentType(responseHeaders: response.allHeaderFields, task: task)
|
||||
// obtain content length override, if needed and available
|
||||
if totalBytesExpectedToWrite == -1 {
|
||||
let contentLength = getContentLength(responseHeaders: response.allHeaderFields, task: task)
|
||||
if contentLength != -1 {
|
||||
BDPlugin.propertyLock.withLock({
|
||||
BDPlugin.tasksWithContentLengthOverride[task.taskId] = contentLength
|
||||
})
|
||||
}
|
||||
}
|
||||
// Check if there is enough space
|
||||
if insufficientSpace(contentLength: totalBytesExpectedToWrite) {
|
||||
let notProgrammaticallyCancelled = BDPlugin.propertyLock.withLock({
|
||||
!BDPlugin.taskIdsProgrammaticallyCanceledAfterStart.contains(task.taskId)
|
||||
})
|
||||
if notProgrammaticallyCancelled {
|
||||
os_log("Error for taskId %@: Insufficient space to store the file to be downloaded", log: log, type: .error, task.taskId)
|
||||
processStatusUpdate(task: task, status: .failed, taskException: TaskException(type: .fileSystem, httpResponseCode: -1, description: "Insufficient space to store the file to be downloaded for taskId \(task.taskId)"))
|
||||
BDPlugin.propertyLock.withLock({
|
||||
_ = BDPlugin.taskIdsProgrammaticallyCanceledAfterStart.insert(task.taskId)
|
||||
})
|
||||
downloadTask.cancel()
|
||||
}
|
||||
return
|
||||
}
|
||||
// check if the task is resumable
|
||||
if task.allowPause {
|
||||
// header lookup must be case-insensitive, and a partial
|
||||
// response also implies the server accepts ranges
|
||||
let httpResponse = downloadTask.response as? HTTPURLResponse
|
||||
let acceptRangesHeader = httpResponse?.value(forHTTPHeaderField: "Accept-Ranges")
|
||||
let taskCanResume = acceptRangesHeader == "bytes" || httpResponse?.statusCode == 206
|
||||
processCanResume(task: task, taskCanResume: taskCanResume)
|
||||
if taskCanResume {
|
||||
BDPlugin.propertyLock.withLock({
|
||||
_ = BDPlugin.taskIdsThatCanResume.insert(task.taskId)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let contentLength = BDPlugin.propertyLock.withLock({
|
||||
let l = totalBytesExpectedToWrite != -1 ? totalBytesExpectedToWrite : BDPlugin.tasksWithContentLengthOverride[task.taskId] ?? -1
|
||||
BDPlugin.remainingBytesToDownload[task.taskId] = l - totalBytesWritten
|
||||
return l
|
||||
})
|
||||
updateProgress(task: task, totalBytesExpected: contentLength, totalBytesDone: totalBytesWritten)
|
||||
}
|
||||
|
||||
/// Process taskdelegate progress update for upload task
|
||||
///
|
||||
/// If the task requires progress updates, provide these at a reasonable interval
|
||||
/// If this is the first update for this file, also emit a 'running' status update
|
||||
public func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
|
||||
let urlSessionTask = task
|
||||
guard let task = getTaskFrom(urlSessionTask: task) else {return}
|
||||
let taskId = task.taskId
|
||||
let progressInfo = BDPlugin.propertyLock.withLock({
|
||||
return BDPlugin.progressInfo[taskId]
|
||||
})
|
||||
if progressInfo == nil {
|
||||
// first call to this method: send 'running' status update
|
||||
processStatusUpdate(task: task, status: TaskStatus.running)
|
||||
processProgressUpdate(task: task, progress: 0.0)
|
||||
BDPlugin.propertyLock.withLock({
|
||||
BDPlugin.progressInfo[task.taskId] = (lastProgressUpdateTime: 0, lastProgressValue: 0.0, lastTotalBytesDone: 0, lastNetworkSpeed: -1.0)
|
||||
})
|
||||
// notify if needed
|
||||
let notificationConfig = getNotificationConfigFrom(urlSessionTask: urlSessionTask)
|
||||
if (notificationConfig != nil) {
|
||||
updateNotification(task: task, notificationType: .running, notificationConfig: notificationConfig)
|
||||
}
|
||||
}
|
||||
updateProgress(task: task, totalBytesExpected: totalBytesExpectedToSend, totalBytesDone: totalBytesSent)
|
||||
}
|
||||
|
||||
/// Process end of downloadTask sent by the urlSession.
|
||||
///
|
||||
/// If successful, (over)write file to final destination per Task info
|
||||
public func urlSession(_ session: URLSession,
|
||||
downloadTask: URLSessionDownloadTask,
|
||||
didFinishDownloadingTo location: URL) {
|
||||
guard var task = getTaskFrom(urlSessionTask: downloadTask),
|
||||
let response = downloadTask.response as? HTTPURLResponse
|
||||
else {
|
||||
os_log("Could not find task associated urlSessionTask %d, or did not get HttpResponse", log: log, type: .info, downloadTask.taskIdentifier)
|
||||
return}
|
||||
let responseHeaders = response.allHeaderFields
|
||||
let taskId = task.taskId
|
||||
let (mimeType, charSet) = BDPlugin.propertyLock.withLock({
|
||||
let mimeType = BDPlugin.mimeTypes.removeValue(forKey: taskId)
|
||||
let charSet = BDPlugin.charSets.removeValue(forKey: taskId)
|
||||
BDPlugin.tasksWithModifications.removeValue(forKey: taskId)
|
||||
BDPlugin.tasksWithContentLengthOverride.removeValue(forKey: taskId)
|
||||
BDPlugin.initialResponseDataProcessed.remove(taskId)
|
||||
return (mimeType, charSet)
|
||||
})
|
||||
let notificationConfig = getNotificationConfigFrom(urlSessionTask: downloadTask)
|
||||
if response.statusCode == 404 {
|
||||
let responseBody = readFile(url: location)
|
||||
processStatusUpdate(task: task, status: TaskStatus.notFound, responseBody: responseBody, responseHeaders: responseHeaders, responseStatusCode: response.statusCode)
|
||||
updateNotification(task: task, notificationType: .error, notificationConfig: notificationConfig)
|
||||
return
|
||||
}
|
||||
if !(200...206).contains(response.statusCode) {
|
||||
os_log("TaskId %@ returned response code %d", log: log, type: .info, task.taskId, response.statusCode)
|
||||
let responseBody = readFile(url: location)
|
||||
processStatusUpdate(task: task, status: TaskStatus.failed, taskException: TaskException(type: .httpResponse, httpResponseCode: response.statusCode, description: responseBody?.isEmpty == false ? responseBody! : HTTPURLResponse.localizedString(forStatusCode: response.statusCode)))
|
||||
if task.retriesRemaining == 0 {
|
||||
// update notification only if no retries remaining
|
||||
updateNotification(task: task, notificationType: .error, notificationConfig: notificationConfig)
|
||||
}
|
||||
return
|
||||
}
|
||||
do {
|
||||
var finalStatus = TaskStatus.failed
|
||||
var taskException: TaskException? = nil
|
||||
var responseBody: String? = nil
|
||||
defer {
|
||||
processStatusUpdate(task: task,
|
||||
status: finalStatus,
|
||||
taskException: taskException,
|
||||
responseBody: responseBody,
|
||||
responseHeaders: responseHeaders,
|
||||
responseStatusCode: response.statusCode,
|
||||
mimeType: mimeType,
|
||||
charSet: charSet)
|
||||
if finalStatus != TaskStatus.failed || task.retriesRemaining == 0 {
|
||||
// update notification only if not failed, or no retries remaining
|
||||
updateNotification(task: task, notificationType: notificationTypeForTaskStatus(status: finalStatus), notificationConfig: notificationConfig)
|
||||
}
|
||||
}
|
||||
if (isDownloadTask(task: task)) {
|
||||
// Determine directoryUri and fileUrl based on URI mode or filepath mode
|
||||
var directoryUrl: URL?
|
||||
var fileUrl: URL?
|
||||
|
||||
// filename field can contain filename and/or a file url (if already started)
|
||||
let unpackedFilename = unpack(packedString: task.filename)
|
||||
let filename = unpackedFilename.filename ?? "unknown"
|
||||
fileUrl = unpackedFilename.uri
|
||||
// directory may contain a path or a Uri representing the full destination directory
|
||||
if let directoryUri = uriFromStringValue(maybePacked: task.directory) {
|
||||
// URI mode
|
||||
let uri = decodeToFileUrl(uri: directoryUri)
|
||||
guard let uri = uri else {
|
||||
os_log("Invalid directory URI (could not convert bookmark): %@", log: log, type: .error, directoryUri.absoluteString)
|
||||
taskException = TaskException(type: .fileSystem, httpResponseCode: -1,
|
||||
description: "Invalid directory URI (could not convert bookmark): %@ \(directoryUri.absoluteString)")
|
||||
return
|
||||
}
|
||||
guard uri.isFileURL else {
|
||||
os_log("Invalid directory URI (not a file URL): %@", log: log, type: .error, uri.absoluteString)
|
||||
taskException = TaskException(type: .fileSystem, httpResponseCode: -1,
|
||||
description: "Invalid directory URI (not a file URL): \(uri.absoluteString)")
|
||||
return
|
||||
}
|
||||
directoryUrl = uri
|
||||
fileUrl = uri.appendingPathComponent(filename)
|
||||
// store the full Uri in the task.filename so it can be retrieved
|
||||
let newTask = task.copyWith(filename: pack(filename: filename, uri: fileUrl!))
|
||||
task = newTask
|
||||
} else {
|
||||
// Filepath mode
|
||||
directoryUrl = try directoryForTask(task: task)
|
||||
fileUrl = directoryUrl?.appendingPathComponent(filename)
|
||||
}
|
||||
|
||||
// Guard against directoryUri or fileUrl being nil
|
||||
guard let directoryUri = directoryUrl, var fileUrl = fileUrl else {
|
||||
os_log("Could not determine directory Uri or file Uri", log: log, type: .error)
|
||||
taskException = TaskException(type: .fileSystem, httpResponseCode: -1,
|
||||
description: "Could not determine directory or file Uri")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
if !FileManager.default.fileExists(atPath: directoryUri.path) {
|
||||
try FileManager.default.createDirectory(at: directoryUri, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
if FileManager.default.fileExists(atPath: fileUrl.path) {
|
||||
try FileManager.default.removeItem(at: fileUrl)
|
||||
}
|
||||
|
||||
try FileManager.default.moveItem(at: location, to: fileUrl)
|
||||
|
||||
do {
|
||||
if UserDefaults.standard.bool(forKey: BDPlugin.keyConfigExcludeFromCloudBackup)
|
||||
{
|
||||
try fileUrl.setCloudBackup(exclude: true)
|
||||
os_log("Excluded from iCloud backup: %@", log: log, type: .info, fileUrl.path)
|
||||
}
|
||||
} catch {
|
||||
os_log("Could not exclude from iCloud backup: %@ - %@", log: log, type: .info, fileUrl.path, error.localizedDescription)
|
||||
}
|
||||
finalStatus = TaskStatus.complete
|
||||
|
||||
} catch {
|
||||
os_log("File operation failed: %@", log: log, type: .error, error.localizedDescription)
|
||||
taskException = TaskException(type: .fileSystem, httpResponseCode: -1, description: "File operation failed: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// this is a DataTask, so we read the file as the responseBody
|
||||
responseBody = readFile(url: location)
|
||||
try? FileManager.default.removeItem(at: location);
|
||||
finalStatus = .complete
|
||||
}
|
||||
} catch {
|
||||
os_log("Uncaught file download error for taskId %@ and file %@: %@", log: log, type: .error, task.taskId, task.filename, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
//MARK: URLSessionDataDelegate
|
||||
|
||||
/// Collects incoming data following a file upload, by appending the data block to a static dictionary keyed by taskId
|
||||
public func urlSession(
|
||||
_ session: URLSession,
|
||||
dataTask: URLSessionDataTask,
|
||||
didReceive data: Data
|
||||
)
|
||||
{
|
||||
guard let task = getTaskFrom(urlSessionTask: dataTask)
|
||||
else {
|
||||
os_log("Could not find task associated urlSessionTask %d", log: log, type: .info, dataTask.taskIdentifier)
|
||||
return
|
||||
}
|
||||
BDPlugin.propertyLock.withLock({
|
||||
var dataList = BDPlugin.responseBodyData[task.taskId] ?? []
|
||||
dataList.append(data)
|
||||
BDPlugin.responseBodyData[task.taskId] = dataList
|
||||
})
|
||||
}
|
||||
|
||||
//MARK: URLSessionDelegate
|
||||
|
||||
/// Upon completion of download of all files, call the completion handler
|
||||
public func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
|
||||
DispatchQueue.main.async {
|
||||
guard
|
||||
let handler = UrlSessionDelegate.backgroundCompletionHandler,
|
||||
session.configuration.identifier == UrlSessionDelegate.sessionIdentifier
|
||||
else {
|
||||
os_log("No handler or no identifier match in urlSessionDidFinishEvents", log: log, type: .info)
|
||||
return
|
||||
}
|
||||
handler()
|
||||
}
|
||||
}
|
||||
|
||||
//MARK: Helpers
|
||||
|
||||
/// Read the contents of a file to a String, or nil if unable
|
||||
private func readFile(url: URL) -> String? {
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
let string = String(data: data, encoding: .utf8)
|
||||
return string
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Get response body for upload task with this [taskId]
|
||||
private func getResponseBody(taskId: String) -> String? {
|
||||
guard let dataList = BDPlugin.propertyLock.withLock( { BDPlugin.responseBodyData[taskId] }) else {
|
||||
return nil
|
||||
}
|
||||
var allData: Data? = nil
|
||||
dataList.forEach { data in
|
||||
if allData == nil {
|
||||
allData = data
|
||||
} else {
|
||||
allData?.append(data)
|
||||
}
|
||||
}
|
||||
if (allData == nil) {
|
||||
return nil
|
||||
}
|
||||
return String(data: allData!, encoding: .utf8)!
|
||||
}
|
||||
|
||||
//MARK: UrlSession and related Task helpers
|
||||
|
||||
/// Creates a urlSession
|
||||
///
|
||||
/// Configures defaultResourceTimeout, defaultRequestTimeout and proxy based on configuration parameters,
|
||||
/// or defaults
|
||||
static func createUrlSession() -> Void {
|
||||
if UrlSessionDelegate.urlSession != nil {
|
||||
return
|
||||
}
|
||||
let config = URLSessionConfiguration.background(withIdentifier: UrlSessionDelegate.sessionIdentifier)
|
||||
let defaults = UserDefaults.standard
|
||||
let storedTimeoutIntervalForResource = defaults.double(forKey: BDPlugin.keyConfigResourceTimeout) // seconds
|
||||
let timeOutIntervalForResource = storedTimeoutIntervalForResource > 0 ? storedTimeoutIntervalForResource : BDPlugin.defaultResourceTimeout
|
||||
os_log("timeoutIntervalForResource = %d seconds", log: log, type: .info, Int(timeOutIntervalForResource))
|
||||
config.timeoutIntervalForResource = timeOutIntervalForResource
|
||||
let storedTimeoutIntervalForRequest = defaults.double(forKey: BDPlugin.keyConfigRequestTimeout) // seconds
|
||||
let timeoutIntervalForRequest = storedTimeoutIntervalForRequest > 0 ? storedTimeoutIntervalForRequest : BDPlugin.defaultRequestTimeout
|
||||
os_log("timeoutIntervalForRequest = %d seconds", log: log, type: .info, Int(timeoutIntervalForRequest))
|
||||
config.timeoutIntervalForRequest = timeoutIntervalForRequest
|
||||
let proxyAddress = defaults.string(forKey: BDPlugin.keyConfigProxyAdress)
|
||||
let proxyPort = defaults.integer(forKey: BDPlugin.keyConfigProxyPort)
|
||||
if (proxyAddress != nil && proxyPort != 0) {
|
||||
config.connectionProxyDictionary = [
|
||||
kCFNetworkProxiesHTTPEnable: true,
|
||||
kCFNetworkProxiesHTTPProxy: proxyAddress!,
|
||||
kCFNetworkProxiesHTTPPort: proxyPort,
|
||||
"HTTPSEnable": true,
|
||||
"HTTPSProxy": proxyAddress!,
|
||||
"HTTPSPort": proxyPort
|
||||
]
|
||||
os_log("Using proxy %@:%d for all tasks", log: log, type: .info, proxyAddress!, proxyPort)
|
||||
} else {
|
||||
os_log("Not using proxy for any task", log: log, type: .info)
|
||||
}
|
||||
UrlSessionDelegate.urlSession = URLSession(configuration: config, delegate: UrlSessionDelegate.instance, delegateQueue: nil)
|
||||
}
|
||||
|
||||
/// Return all tasks in this urlSession
|
||||
static func getAllTasks() async -> [Task] {
|
||||
UrlSessionDelegate.createUrlSession()
|
||||
guard let urlSessionTasks = await UrlSessionDelegate.urlSession?.allTasks else { return [] }
|
||||
return urlSessionTasks.compactMap({ getTaskFrom(urlSessionTask: $0) })
|
||||
}
|
||||
|
||||
/// Return the active task with this taskId, or nil
|
||||
static func getTaskWithId(taskId: String) async -> Task? {
|
||||
return await getAllTasks().first(where: { $0.taskId == taskId })
|
||||
}
|
||||
|
||||
/// Return all urlSessionsTasks in this urlSession
|
||||
static func getAllUrlSessionTasks(group: String? = nil) async -> [URLSessionTask] {
|
||||
UrlSessionDelegate.createUrlSession()
|
||||
if (group == nil) {
|
||||
guard let urlSessionTasks = await UrlSessionDelegate.urlSession?.allTasks else { return [] }
|
||||
return urlSessionTasks
|
||||
}
|
||||
guard let urlSessionTasks = await UrlSessionDelegate.urlSession?.allTasks else { return [] }
|
||||
let urlSessionTasksInGroup = urlSessionTasks.filter({
|
||||
guard let task = getTaskFrom(urlSessionTask: $0) else { return false }
|
||||
return task.group == group
|
||||
})
|
||||
return urlSessionTasksInGroup
|
||||
}
|
||||
|
||||
/// Return the urlSessionTask matching this taskId, or nil
|
||||
static func getUrlSessionTaskWithId(taskId: String) async -> URLSessionTask? {
|
||||
guard let urlSessionTask = await getAllUrlSessionTasks().first(where: {
|
||||
guard let task = getTaskFrom(urlSessionTask: $0) else { return false }
|
||||
return task.taskId == taskId
|
||||
}) else { return nil }
|
||||
return urlSessionTask
|
||||
}
|
||||
|
||||
/// Returns a `task` that may be modified through callbacks, and a bool to indicate that the task was modified.
|
||||
///
|
||||
/// Callbacks would be attached to the task via its `Task.options` property, and if
|
||||
/// present will be invoked by starting a taskDispatcher on a background isolate, then
|
||||
/// sending the callback request via the MethodChannel.
|
||||
///
|
||||
/// First test is for auth refresh (the onAuth callback), then the onStart callback. Both
|
||||
/// callbacks run in a Dart isolate, and may return a modified task, which will be used
|
||||
/// for the actual task execution.
|
||||
func getModifiedTask(task: Task) async -> (Task, Bool) {
|
||||
var authTask: Task?
|
||||
var taskWasModified: Bool = false
|
||||
if let auth = task.options?.auth {
|
||||
// Refresh token if needed
|
||||
if auth.isTokenExpired() && auth.hasOnAuthCallback() == true {
|
||||
authTask = await invokeOnAuthCallback(task: task)
|
||||
}
|
||||
taskWasModified = authTask != nil
|
||||
authTask = authTask ?? task // Either original or newly authorized
|
||||
guard let newAuth = authTask?.options?.auth else { return (authTask ?? task, taskWasModified) }
|
||||
// Insert query parameters and headers
|
||||
taskWasModified = true
|
||||
let uri = newAuth.addOrUpdateQueryParams(
|
||||
url: authTask!.url,
|
||||
queryParams: newAuth.getExpandedAccessQueryParams()
|
||||
)
|
||||
var headers = authTask!.headers
|
||||
headers.merge(newAuth.getExpandedAccessHeaders()) { (_, new) in new }
|
||||
authTask = authTask!.copyWith(url: uri.absoluteString, headers: headers)
|
||||
}
|
||||
authTask = authTask ?? task
|
||||
guard task.options?.hasOnStartCallback() == true else { return (authTask!, taskWasModified) }
|
||||
// onStart callback
|
||||
let modifiedTask = await invokeOnTaskStartCallback(task: authTask!)
|
||||
taskWasModified = taskWasModified || modifiedTask != nil
|
||||
return (modifiedTask ?? authTask!, taskWasModified)
|
||||
}
|
||||
}
|
||||
third_party/background_downloader/ios/background_downloader/Sources/background_downloader/WiFi.swift
Vendored
+104
@@ -0,0 +1,104 @@
|
||||
//
|
||||
// WiFi.swift
|
||||
// background_downloader
|
||||
//
|
||||
// Created by Bram on 1/19/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
/// WiFi requirement modes at the application level
|
||||
enum RequireWiFi: Int {
|
||||
case
|
||||
asSetByTask,
|
||||
forAllTasks,
|
||||
forNoTasks
|
||||
}
|
||||
|
||||
/// Manages changes to WiFi requirement, by re-enqueuing tasks if needed
|
||||
class WiFiQueue {
|
||||
static let shared = WiFiQueue()
|
||||
private let requireWiFiChangeQueue = DispatchQueue(label: "com.bbflight.background_downloader.requireWiFiChangeQueue", target: DispatchQueue.global())
|
||||
private let reEnqueueQueue = DispatchQueue(label: "com.bbflight.background_downloader.reEnqueueQueue", target: DispatchQueue.global())
|
||||
private let requireWiFiChangeSemaphore = DispatchSemaphore(value: 0)
|
||||
|
||||
// Private initializer to prevent creating new instances
|
||||
private init() {}
|
||||
|
||||
// Change the application level WiFi requirement and re-enqueue tasks as necessary
|
||||
func requireWiFiChange(requireWiFi: RequireWiFi, rescheduleRunningTasks: Bool, alsoRestartUploads: Bool) {
|
||||
requireWiFiChangeQueue.async {
|
||||
_Concurrency.Task {
|
||||
BDPlugin.requireWiFi = requireWiFi
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.setValue(requireWiFi.rawValue, forKey: BDPlugin.keyRequireWiFi)
|
||||
UrlSessionDelegate.createUrlSession()
|
||||
guard let urlSessionTasks = await UrlSessionDelegate.urlSession?.allTasks
|
||||
else {
|
||||
self.reEnqueuesDone()
|
||||
return
|
||||
}
|
||||
var haveReEnqueued = false
|
||||
urlSessionTasks.forEach { urlSessionTask in
|
||||
if ((urlSessionTask is URLSessionDownloadTask || urlSessionTask is URLSessionUploadTask) && (urlSessionTask.state == .running || urlSessionTask.state == .suspended)) {
|
||||
guard let task = getTaskFrom(urlSessionTask: urlSessionTask) else {
|
||||
return
|
||||
}
|
||||
// compare with the request the task was created with, which the URLSession
|
||||
// keeps across app launches, so tasks created by an earlier launch are included
|
||||
let urlSessionTaskRequiresWiFi = urlSessionTask.originalRequest?.allowsCellularAccess == false
|
||||
BDPlugin.propertyLock.withLock {
|
||||
if taskRequiresWiFi(task: task) != urlSessionTaskRequiresWiFi {
|
||||
// requirement differs, so we need to re-enqueue
|
||||
if BDPlugin.progressInfo[task.taskId] == nil {
|
||||
// enqueued only, so ensure it is re-enqueued and cancel
|
||||
haveReEnqueued = true
|
||||
BDPlugin.tasksToReEnqueue.insert(task)
|
||||
urlSessionTask.cancel()
|
||||
} else {
|
||||
if (rescheduleRunningTasks && isDownloadTask(task: task)) || (alsoRestartUploads && isUploadTask(task: task)) {
|
||||
// cancel without resume data so the new URLRequest (with the updated
|
||||
// allowsCellularAccess setting) is used; downloadTask(withResumeData:)
|
||||
// would reuse the request embedded in the resume data
|
||||
haveReEnqueued = true
|
||||
BDPlugin.tasksToReEnqueue.insert(task)
|
||||
urlSessionTask.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !haveReEnqueued {
|
||||
self.reEnqueuesDone() // nothing left to do
|
||||
}
|
||||
}
|
||||
self.requireWiFiChangeSemaphore.wait() // wait for re-enqueues to complete
|
||||
}
|
||||
}
|
||||
|
||||
func reEnqueuesDone() {
|
||||
self.requireWiFiChangeSemaphore.signal()
|
||||
}
|
||||
|
||||
|
||||
/// Re-enqueue this task and associated data. Nil signals end of batch
|
||||
func reEnqueue(_ enqueueItem: EnqueueItem?) {
|
||||
reEnqueueQueue.async {
|
||||
_Concurrency.Task {
|
||||
guard let reEnqueueData = enqueueItem else {
|
||||
// nil value indicates end of batch of re-enqueues
|
||||
self.reEnqueuesDone()
|
||||
return
|
||||
}
|
||||
let timeSinceCreated = Date().timeIntervalSince(reEnqueueData.created)
|
||||
if timeSinceCreated < 0.3 {
|
||||
try await _Concurrency.Task.sleep(nanoseconds: UInt64((0.3 - timeSinceCreated) * 1_000_000_000))
|
||||
}
|
||||
await enqueueItem?.enqueue()
|
||||
try await _Concurrency.Task.sleep(nanoseconds: 20_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/// A comprehensive background file downloader and uploader for iOS, Android, Desktop and Web
|
||||
///
|
||||
/// Use [FileDownloader] to download and upload files.
|
||||
library;
|
||||
|
||||
export 'src/database.dart';
|
||||
export 'src/exceptions.dart';
|
||||
export 'src/file_downloader.dart';
|
||||
export 'src/models.dart';
|
||||
export 'src/options/auth.dart';
|
||||
export 'src/options/auth_callback.dart';
|
||||
export 'src/options/mtls.dart';
|
||||
export 'src/options/task_options.dart';
|
||||
export 'src/permissions.dart' show PermissionType, PermissionStatus;
|
||||
export 'src/persistent_storage.dart';
|
||||
export 'src/progress_widget.dart';
|
||||
export 'src/queue/task_queue.dart';
|
||||
export 'src/task.dart';
|
||||
export 'src/transfer.dart';
|
||||
export 'src/transfers.dart';
|
||||
export 'src/transfer_hint.dart';
|
||||
export 'src/uri/uri_utils.dart';
|
||||
export 'src/widgets/transfer_button.dart';
|
||||
export 'src/widgets/transfer_list_tile.dart';
|
||||
export 'src/widgets/transfer_progress_bar.dart';
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,119 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'base_downloader.dart';
|
||||
import 'file_downloader.dart';
|
||||
import 'models.dart';
|
||||
import 'task.dart';
|
||||
|
||||
/// Class representing a chunk of a download and its status
|
||||
class Chunk {
|
||||
// key parameters
|
||||
final String parentTaskId;
|
||||
final String url;
|
||||
final String filename;
|
||||
final int fromByte; // start byte
|
||||
final int toByte; // end byte
|
||||
final DownloadTask task; // task to download this chunk
|
||||
|
||||
// state parameters
|
||||
late TaskStatus status;
|
||||
late double progress;
|
||||
|
||||
/// Define a chunk by its key parameters, in default state
|
||||
///
|
||||
/// This also generates the [task] to download this chunk, and that
|
||||
/// task contains the [parentTaskId] and [toByte] and [fromByte] values of the
|
||||
/// chunk in its [Task.metaData] field as a JSON encoded map
|
||||
Chunk({
|
||||
required Task parentTask,
|
||||
required this.url,
|
||||
required this.filename,
|
||||
required this.fromByte,
|
||||
required this.toByte,
|
||||
}) : parentTaskId = parentTask.taskId,
|
||||
task = DownloadTask(
|
||||
url: url,
|
||||
filename: filename,
|
||||
headers: {...parentTask.headers, 'Range': 'bytes=$fromByte-$toByte'},
|
||||
baseDirectory: BaseDirectory.temporary,
|
||||
group: BaseDownloader.chunkGroup,
|
||||
updates: updatesBasedOnParent(parentTask),
|
||||
retries: parentTask.retries,
|
||||
allowPause: parentTask.allowPause,
|
||||
priority: parentTask.priority,
|
||||
requiresWiFi: parentTask.requiresWiFi,
|
||||
metaData: jsonEncode({
|
||||
'parentTaskId': parentTask.taskId,
|
||||
'from': fromByte,
|
||||
'to': toByte,
|
||||
}),
|
||||
) {
|
||||
status = TaskStatus.enqueued;
|
||||
progress = 0;
|
||||
}
|
||||
|
||||
/// Creates object from [json]
|
||||
Chunk.fromJson(Map<String, dynamic> json)
|
||||
: parentTaskId = json['parentTaskId'],
|
||||
url = json['url'],
|
||||
filename = json['filename'],
|
||||
fromByte = (json['fromByte'] as num).toInt(),
|
||||
toByte = (json['toByte'] as num).toInt(),
|
||||
task = Task.createFromJson(json['task']) as DownloadTask,
|
||||
status = TaskStatus.values[(json['status'] as num? ?? 0).toInt()],
|
||||
progress = (json['progress'] as num? ?? 0.0).toDouble();
|
||||
|
||||
/// Revive `List<Chunk>` from a JSON map in a jsonDecode operation,
|
||||
/// where each element is a map representing the [Chunk]
|
||||
static Object? listReviver(Object? key, Object? value) =>
|
||||
key is int ? Chunk.fromJson(value as Map<String, dynamic>) : value;
|
||||
|
||||
/// Creates JSON map of this object
|
||||
Map<String, dynamic> toJson() => {
|
||||
'parentTaskId': parentTaskId,
|
||||
'url': url,
|
||||
'filename': filename,
|
||||
'fromByte': fromByte,
|
||||
'toByte': toByte,
|
||||
'task': task.toJson(),
|
||||
'status': status.index,
|
||||
'progress': progress,
|
||||
};
|
||||
|
||||
/// Return the parentTaskId embedded in the metaData of a chunkTask
|
||||
static String getParentTaskId(Task task) =>
|
||||
jsonDecode(task.metaData)['parentTaskId'] as String;
|
||||
|
||||
/// Return [Updates] that is based on the [parentTask]
|
||||
static Updates updatesBasedOnParent(Task parentTask) =>
|
||||
switch (parentTask.updates) {
|
||||
.none || .status => Updates.status,
|
||||
.progress || .statusAndProgress => Updates.statusAndProgress,
|
||||
};
|
||||
}
|
||||
|
||||
/// Resume all chunk tasks associated with this [task], and
|
||||
/// return true if successful, otherwise cancels this [task]
|
||||
/// which will also cancel all chunk tasks
|
||||
Future<bool> resumeChunkTasks(
|
||||
ParallelDownloadTask task,
|
||||
ResumeData resumeData,
|
||||
) async {
|
||||
final chunks = List<Chunk>.from(
|
||||
jsonDecode(resumeData.data, reviver: Chunk.listReviver),
|
||||
);
|
||||
final results = await Future.wait(
|
||||
// Completed chunks retain their final file/status but have no resume data.
|
||||
// Resuming them returns false and incorrectly cancels the whole parent.
|
||||
chunks
|
||||
.where((chunk) => chunk.status != TaskStatus.complete)
|
||||
.map((chunk) => FileDownloader().resume(chunk.task)),
|
||||
);
|
||||
if (results.any((result) => result == false)) {
|
||||
// cancel [ParallelDownloadTask] if any resume did not succeed.
|
||||
// this will also cancel all chunk tasks
|
||||
await FileDownloader().cancelTaskWithId(task.taskId);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'base_downloader.dart';
|
||||
import 'exceptions.dart';
|
||||
import 'models.dart';
|
||||
import 'persistent_storage.dart';
|
||||
import 'task.dart';
|
||||
|
||||
/// Persistent database used for tracking task status and progress.
|
||||
///
|
||||
/// Stores [TaskRecord] objects.
|
||||
///
|
||||
/// This object is accessed by the [FileDownloader] and [BaseDownloader]
|
||||
interface class Database {
|
||||
static Database? _instance;
|
||||
late final PersistentStorage _storage;
|
||||
final StreamController<TaskRecord> _controller =
|
||||
StreamController<TaskRecord>.broadcast();
|
||||
final _log = Logger('Database');
|
||||
|
||||
/// Return the [Database] singleton instance, and creates it if required
|
||||
factory Database(PersistentStorage persistentStorage) {
|
||||
_instance ??= Database._internal(persistentStorage);
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
Database._internal(PersistentStorage persistentStorage) {
|
||||
assert(_instance == null);
|
||||
_storage = persistentStorage;
|
||||
}
|
||||
|
||||
/// Direct access to the [PersistentStorage] object underlying the
|
||||
/// database. For testing only
|
||||
@visibleForTesting
|
||||
PersistentStorage get storage => _storage;
|
||||
|
||||
/// Stream of [TaskRecord] updates, emitted after a record is updated in
|
||||
/// [PersistentStorage]
|
||||
Stream<TaskRecord> get updates => _controller.stream;
|
||||
|
||||
/// Returns all [TaskRecord]
|
||||
///
|
||||
/// Optionally, specify a [group] to filter by
|
||||
Future<List<TaskRecord>> allRecords({String? group}) async {
|
||||
final allRecords = await _storage.retrieveAllTaskRecords();
|
||||
return group == null
|
||||
? allRecords.toList()
|
||||
: allRecords.where((element) => element.group == group).toList();
|
||||
}
|
||||
|
||||
/// Returns all [TaskRecord] older than [age]
|
||||
///
|
||||
/// Optionally, specify a [group] to filter by
|
||||
Future<List<TaskRecord>> allRecordsOlderThan(
|
||||
Duration age, {
|
||||
String? group,
|
||||
}) async {
|
||||
final allRecordsInGroup = await allRecords(group: group);
|
||||
final now = DateTime.now();
|
||||
return allRecordsInGroup
|
||||
.where((record) => now.difference(record.task.creationTime) > age)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Returns all [TaskRecord] with [TaskStatus] [status]
|
||||
///
|
||||
/// Optionally, specify a [group] to filter by
|
||||
Future<List<TaskRecord>> allRecordsWithStatus(
|
||||
TaskStatus status, {
|
||||
String? group,
|
||||
}) async {
|
||||
final allRecordsInGroup = await allRecords(group: group);
|
||||
return allRecordsInGroup
|
||||
.where((record) => record.status == status)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Return [TaskRecord] for this [taskId] or null if not found
|
||||
Future<TaskRecord?> recordForId(String taskId) =>
|
||||
_storage.retrieveTaskRecord(taskId);
|
||||
|
||||
/// Return list of [TaskRecord] corresponding to the [taskIds]
|
||||
///
|
||||
/// Only records that can be found in the database will be included in the
|
||||
/// list. TaskIds that cannot be found will be ignored.
|
||||
Future<List<TaskRecord>> recordsForIds(Iterable<String> taskIds) async {
|
||||
final records = await Future.wait(taskIds.map((id) => recordForId(id)));
|
||||
return records.whereType<TaskRecord>().toList();
|
||||
}
|
||||
|
||||
/// Delete all records
|
||||
///
|
||||
/// Optionally, specify a [group] to filter by
|
||||
Future<void> deleteAllRecords({String? group}) async {
|
||||
if (group == null) {
|
||||
await _storage.removeTaskRecord(null);
|
||||
_updateCount = 0;
|
||||
return;
|
||||
}
|
||||
final allRecordsInGroup = await allRecords(group: group);
|
||||
await deleteRecordsWithIds(
|
||||
allRecordsInGroup.map((record) => record.taskId),
|
||||
);
|
||||
_updateCount = 0;
|
||||
}
|
||||
|
||||
/// Delete record with this [taskId]
|
||||
Future<void> deleteRecordWithId(String taskId) =>
|
||||
deleteRecordsWithIds([taskId]);
|
||||
|
||||
/// Delete records with these [taskIds]
|
||||
Future<void> deleteRecordsWithIds(Iterable<String> taskIds) async {
|
||||
await Future.wait(
|
||||
taskIds.map((taskId) => _storage.removeTaskRecord(taskId)),
|
||||
);
|
||||
}
|
||||
|
||||
int _updateCount = 0;
|
||||
bool _autoClean = false;
|
||||
int? _maxRecordCount;
|
||||
Duration? _maxAge;
|
||||
bool _isCleaning = false;
|
||||
bool _waitingToClean = false;
|
||||
|
||||
/// Whether automatic database cleanup is enabled
|
||||
bool get autoClean => _autoClean;
|
||||
|
||||
/// Clean up the database by removing old records and/or keeping the number
|
||||
/// of records below a maximum.
|
||||
///
|
||||
/// The [maxRecordCount] determines the maximum number of records to keep.
|
||||
/// The [maxAge] determines the maximum age of records to keep.
|
||||
/// If [autoClean] is true, the database will be cleaned automatically
|
||||
/// every 100th update.
|
||||
///
|
||||
/// If both [maxRecordCount] and [maxAge] are null, the defaults are 500
|
||||
/// records and 10 days.
|
||||
///
|
||||
/// The function returns immediately, but the actual cleanup happens
|
||||
/// asynchronously and is rate-limited to deleting 5 records per second.
|
||||
void cleanUp({int? maxRecordCount, Duration? maxAge, bool autoClean = true}) {
|
||||
_autoClean = autoClean;
|
||||
if (maxRecordCount == null && maxAge == null) {
|
||||
_maxRecordCount = 500;
|
||||
_maxAge = const Duration(days: 10);
|
||||
} else {
|
||||
_maxRecordCount = maxRecordCount;
|
||||
_maxAge = maxAge;
|
||||
}
|
||||
_cleanDatabase();
|
||||
}
|
||||
|
||||
Future<void> _cleanDatabase() async {
|
||||
if (_isCleaning) {
|
||||
_waitingToClean = true;
|
||||
return;
|
||||
}
|
||||
_isCleaning = true;
|
||||
do {
|
||||
_waitingToClean = false;
|
||||
try {
|
||||
final allRecords = await this.allRecords();
|
||||
allRecords.sort(
|
||||
(a, b) => b.task.creationTime.compareTo(a.task.creationTime),
|
||||
); // Newest first
|
||||
|
||||
final recordsToDelete = <TaskRecord>{};
|
||||
final now = DateTime.now();
|
||||
// Check age
|
||||
if (_maxAge != null) {
|
||||
final maxAge = _maxAge!;
|
||||
recordsToDelete.addAll(
|
||||
allRecords.where(
|
||||
(record) => now.difference(record.task.creationTime) > maxAge,
|
||||
),
|
||||
);
|
||||
}
|
||||
// Check count
|
||||
if (_maxRecordCount != null) {
|
||||
final count = _maxRecordCount!;
|
||||
if (allRecords.length > count) {
|
||||
// Because we sorted newly created first, the ones after 'count' are the oldest
|
||||
final recordsBeyondCount = allRecords.skip(count);
|
||||
recordsToDelete.addAll(recordsBeyondCount);
|
||||
}
|
||||
}
|
||||
_log.finest(
|
||||
'Database cleanup: ${recordsToDelete.length} out of ${allRecords.length} records to delete',
|
||||
);
|
||||
if (recordsToDelete.isNotEmpty) {
|
||||
// Rate limit deletion to ~5 per second
|
||||
for (final record in recordsToDelete) {
|
||||
await deleteRecordWithId(record.taskId);
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
}
|
||||
_log.finest(
|
||||
'Database cleanup: ${recordsToDelete.length} records deleted}',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_log.warning('Error during database cleanup: $e');
|
||||
}
|
||||
} while (_waitingToClean);
|
||||
_isCleaning = false;
|
||||
}
|
||||
|
||||
/// Update or insert the record in the database
|
||||
///
|
||||
/// This is used by the [FileDownloader] to track tasks, and should not
|
||||
/// normally be used by the user of this package
|
||||
Future<void> updateRecord(TaskRecord record) async {
|
||||
if (_autoClean) {
|
||||
_updateCount++;
|
||||
if (_updateCount % 100 == 0) {
|
||||
_cleanDatabase();
|
||||
}
|
||||
}
|
||||
await _storage.storeTaskRecord(record);
|
||||
if (_controller.hasListener) {
|
||||
_controller.add(record);
|
||||
}
|
||||
}
|
||||
|
||||
/// Destroy the [Database] singleton instance
|
||||
///
|
||||
/// For testing purposes only
|
||||
void destroy() {
|
||||
_instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Record containing task, task status and task progress.
|
||||
///
|
||||
/// [TaskRecord] represents the state of the task as recorded in persistent
|
||||
/// storage if [FileDownloader.trackTasks] has been called to activate this.
|
||||
final class TaskRecord {
|
||||
final Task task;
|
||||
final TaskStatus status;
|
||||
final double progress;
|
||||
final int expectedFileSize;
|
||||
final TaskException? exception;
|
||||
|
||||
TaskRecord(
|
||||
this.task,
|
||||
this.status,
|
||||
this.progress,
|
||||
this.expectedFileSize, [
|
||||
this.exception,
|
||||
]);
|
||||
|
||||
/// Returns the group collection this record is stored under, which is
|
||||
/// the [task]'s [Task.group]
|
||||
String get group => task.group;
|
||||
|
||||
/// Returns the record id, which is the [task]'s [Task.taskId]
|
||||
String get taskId => task.taskId;
|
||||
|
||||
/// Create [TaskRecord] from [json]
|
||||
TaskRecord.fromJson(Map<String, dynamic> json)
|
||||
: task = Task.createFromJson(json),
|
||||
status = TaskStatus
|
||||
.values[(json['status'] as num?)?.toInt() ?? TaskStatus.failed.index],
|
||||
progress = (json['progress'] as num?)?.toDouble() ?? progressFailed,
|
||||
expectedFileSize = (json['expectedFileSize'] as num?)?.toInt() ?? -1,
|
||||
exception = json['exception'] == null
|
||||
? null
|
||||
: TaskException.fromJson(json['exception']);
|
||||
|
||||
/// Returns JSON map representation of this [TaskRecord]
|
||||
///
|
||||
/// Note the [status], [progress] and [exception] fields are merged into
|
||||
/// the JSON map representation of the [task]
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = task.toJson();
|
||||
json['status'] = status.index;
|
||||
json['progress'] = progress;
|
||||
json['expectedFileSize'] = expectedFileSize;
|
||||
json['exception'] = exception?.toJson();
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Copy with optional replacements. [exception] is always copied
|
||||
TaskRecord copyWith({
|
||||
Task? task,
|
||||
TaskStatus? status,
|
||||
double? progress,
|
||||
int? expectedFileSize,
|
||||
}) => TaskRecord(
|
||||
task ?? this.task,
|
||||
status ?? this.status,
|
||||
progress ?? this.progress,
|
||||
expectedFileSize ?? this.expectedFileSize,
|
||||
exception,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'DatabaseRecord{task: $task, status: $status, progress: $progress,'
|
||||
' expectedFileSize: $expectedFileSize, exception: $exception}';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is TaskRecord &&
|
||||
runtimeType == other.runtimeType &&
|
||||
task == other.task &&
|
||||
status == other.status &&
|
||||
progress == other.progress &&
|
||||
expectedFileSize == other.expectedFileSize &&
|
||||
exception == other.exception;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
task.hashCode ^ status.hashCode ^ progress.hashCode ^ exception.hashCode;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../background_downloader.dart';
|
||||
import 'desktop_downloader.dart';
|
||||
import 'download_isolate.dart';
|
||||
import 'isolate.dart';
|
||||
|
||||
/// Do the data task
|
||||
///
|
||||
/// Sends updates via the [sendPort] and can be commanded to cancel via
|
||||
/// the [messagesToIsolate] queue
|
||||
Future<void> doDataTask(
|
||||
DataTask task,
|
||||
Duration requestTimeout,
|
||||
SendPort sendPort,
|
||||
) async {
|
||||
final client = DesktopDownloader.httpClientForUrl(task.url);
|
||||
final request = http.Request(task.httpRequestMethod, Uri.parse(task.url));
|
||||
request.headers.addAll(task.headers);
|
||||
request.maxRedirects = defaultMaxRedirects;
|
||||
if (task.post is String) {
|
||||
request.body = task.post!;
|
||||
}
|
||||
var resultStatus = TaskStatus.failed;
|
||||
try {
|
||||
final response = await client.send(request).timeout(requestTimeout);
|
||||
if (!isCanceled) {
|
||||
responseHeaders = response.headers;
|
||||
responseStatusCode = response.statusCode;
|
||||
extractContentType(response.headers);
|
||||
responseBody = await responseContent(response);
|
||||
if (okResponses.contains(response.statusCode)) {
|
||||
resultStatus = TaskStatus.complete;
|
||||
} else {
|
||||
if (response.statusCode == 404) {
|
||||
resultStatus = TaskStatus.notFound;
|
||||
} else {
|
||||
taskException = TaskHttpException(
|
||||
responseBody?.isNotEmpty == true
|
||||
? responseBody!
|
||||
: response.reasonPhrase ?? 'Invalid HTTP Request',
|
||||
response.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logError(task, e.toString());
|
||||
setTaskError(e);
|
||||
}
|
||||
if (isCanceled) {
|
||||
// cancellation overrides other results
|
||||
resultStatus = TaskStatus.canceled;
|
||||
}
|
||||
processStatusUpdateInIsolate(task, resultStatus, sendPort);
|
||||
}
|
||||
@@ -0,0 +1,930 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:async/async.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/io_client.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../base_downloader.dart';
|
||||
import '../chunk.dart';
|
||||
import '../exceptions.dart';
|
||||
import '../file_downloader.dart';
|
||||
import '../models.dart';
|
||||
import '../task.dart';
|
||||
import '../utils.dart';
|
||||
import 'isolate.dart';
|
||||
|
||||
const okResponses = [200, 201, 202, 203, 204, 205, 206];
|
||||
|
||||
/// Implementation of download functionality for desktop platforms
|
||||
///
|
||||
/// On desktop (MacOS, Linux, Windows) the download and upload are implemented
|
||||
/// in Dart, as there is no native platform equivalent of URLSession or
|
||||
/// WorkManager as there is on iOS and Android
|
||||
final class DesktopDownloader extends BaseDownloader {
|
||||
static final _log = Logger('DesktopDownloader');
|
||||
static const unlimited = 1 << 20;
|
||||
var maxConcurrent = 10;
|
||||
var maxConcurrentByHost = unlimited;
|
||||
var maxConcurrentByGroup = unlimited;
|
||||
static final DesktopDownloader _singleton = DesktopDownloader._internal();
|
||||
final _queue = PriorityQueue<Task>();
|
||||
final _running = Queue<Task>(); // subset that is running
|
||||
final _resume = <Task>{};
|
||||
final _isolateSendPorts =
|
||||
<Task, SendPort?>{}; // isolate SendPort for running task
|
||||
static final Map<String, http.Client> _clientsCache = {};
|
||||
static http.Client? _defaultClient;
|
||||
static Duration? _requestTimeout;
|
||||
static var _proxy = <String, dynamic>{}; // 'address' and 'port'
|
||||
static var _bypassTLSCertificateValidation = false;
|
||||
static var _mtlsConfigs = <MTLSConfig>[];
|
||||
static int _skipExistingFiles = -1;
|
||||
static String? tempFilePath;
|
||||
|
||||
factory() => _singleton;
|
||||
|
||||
new _internal();
|
||||
|
||||
@override
|
||||
Future<bool> enqueue(Task task) async {
|
||||
try {
|
||||
Uri.decodeFull(task.url);
|
||||
} catch (e) {
|
||||
_log.fine('Invalid url: ${task.url} error: $e');
|
||||
return false;
|
||||
}
|
||||
super.enqueue(task);
|
||||
_queue.add(task);
|
||||
processStatusUpdate(TaskStatusUpdate(task, TaskStatus.enqueued));
|
||||
_advanceQueue();
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<bool>> enqueueAll(Iterable<Task> tasks) async {
|
||||
final results = <bool>[];
|
||||
for (final task in tasks) {
|
||||
results.add(await enqueue(task));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/// Advance the queue if it's not empty and there is room in the run queue
|
||||
void _advanceQueue() {
|
||||
while (_running.length < maxConcurrent && _queue.isNotEmpty) {
|
||||
final task = _getNextTask();
|
||||
if (task != null) {
|
||||
_running.add(task);
|
||||
_executeTask(task)
|
||||
.catchError((Object e, StackTrace stackTrace) {
|
||||
// an unexpected error must not leave the task without a final
|
||||
// status, or keep occupying its slot in the run queue
|
||||
_log.warning('Error executing taskId ${task.taskId}: $e');
|
||||
_log.fine('Stack trace: $stackTrace');
|
||||
if (!_running.contains(task)) {
|
||||
return; // final status was already sent
|
||||
}
|
||||
processStatusUpdate(
|
||||
TaskStatusUpdate(
|
||||
task,
|
||||
TaskStatus.failed,
|
||||
TaskException('Error executing task: $e'),
|
||||
),
|
||||
);
|
||||
})
|
||||
.whenComplete(() {
|
||||
_remove(task);
|
||||
_advanceQueue();
|
||||
});
|
||||
} else {
|
||||
return; // if no suitable task, done
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a [Task] to run, or null if no suitable task is available
|
||||
Task? _getNextTask() {
|
||||
final tasksThatHaveToWait = <Task>[];
|
||||
while (_queue.isNotEmpty) {
|
||||
final task = _queue.removeFirst();
|
||||
if (_numActiveWithHostname(task.hostName) < maxConcurrentByHost &&
|
||||
_numActiveWithGroup(task.group) < maxConcurrentByGroup) {
|
||||
_queue.addAll(tasksThatHaveToWait); // put back in queue
|
||||
return task;
|
||||
}
|
||||
tasksThatHaveToWait.add(task);
|
||||
}
|
||||
_queue.addAll(tasksThatHaveToWait); // put back in queue
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Returns number of tasks active with this [hostname]
|
||||
int _numActiveWithHostname(String hostname) => _running.fold(
|
||||
0,
|
||||
(previousValue, task) =>
|
||||
task.hostName == hostname ? previousValue + 1 : previousValue,
|
||||
);
|
||||
|
||||
/// Returns number of tasks active with this [group]
|
||||
int _numActiveWithGroup(String group) => _running.fold(
|
||||
0,
|
||||
(previousValue, task) =>
|
||||
task.group == group ? previousValue + 1 : previousValue,
|
||||
);
|
||||
|
||||
/// Execute this task
|
||||
///
|
||||
/// The task runs on an Isolate, which is sent the task information and
|
||||
/// which will emit status and progress updates. These updates will be
|
||||
/// 'forwarded' to the [backgroundChannel] and processed by the
|
||||
/// [FileDownloader]
|
||||
Future<void> _executeTask(Task task) async {
|
||||
// Check if the file should be skipped
|
||||
// (not possible if the filename will be suggested by the server)
|
||||
if (task is DownloadTask && _skipExistingFiles != -1 && task.hasFilename) {
|
||||
final filePath = await task.filePath();
|
||||
final file = File(filePath);
|
||||
if (await file.exists()) {
|
||||
final fileSize = await file.length();
|
||||
// threshold 0 skips any existing file, including an empty one
|
||||
if (_skipExistingFiles == 0 ||
|
||||
fileSize > _skipExistingFiles * 1024 * 1024) {
|
||||
processStatusUpdate(
|
||||
TaskStatusUpdate(task, TaskStatus.complete, null, null, null, 304),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final resumeData = await getResumeData(task.taskId);
|
||||
if (resumeData != null) {
|
||||
await removeResumeData(task.taskId);
|
||||
}
|
||||
final isResume = _resume.remove(task) && resumeData != null;
|
||||
// spawn an isolate to do the task
|
||||
final receivePort = ReceivePort();
|
||||
final errorPort = ReceivePort();
|
||||
errorPort.listen((message) {
|
||||
final exceptionDescription = (message as List).first as String;
|
||||
final stackTrace = message.last;
|
||||
logError(task, exceptionDescription);
|
||||
log.fine('Stack trace: $stackTrace');
|
||||
processStatusUpdate(
|
||||
TaskStatusUpdate(
|
||||
task,
|
||||
TaskStatus.failed,
|
||||
TaskException(exceptionDescription),
|
||||
),
|
||||
);
|
||||
receivePort.close(); // also ends listener at the end
|
||||
});
|
||||
final RootIsolateToken? rootIsolateToken = RootIsolateToken.instance;
|
||||
if (rootIsolateToken == null) {
|
||||
processStatusUpdate(
|
||||
TaskStatusUpdate(
|
||||
task,
|
||||
TaskStatus.failed,
|
||||
TaskException('Could not obtain rootIsolateToken'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
log.finer('${isResume ? "Resuming" : "Starting"} taskId ${task.taskId}');
|
||||
await Isolate.spawn(doTask, (
|
||||
rootIsolateToken,
|
||||
receivePort.sendPort,
|
||||
), onError: errorPort.sendPort);
|
||||
final messagesFromIsolate = StreamQueue<dynamic>(receivePort);
|
||||
if (!await messagesFromIsolate.hasNext) {
|
||||
return; // isolate failed before starting, reported via errorPort
|
||||
}
|
||||
final sendPort = await messagesFromIsolate.next as SendPort;
|
||||
sendPort.send((
|
||||
task,
|
||||
resumeData,
|
||||
isResume,
|
||||
requestTimeout,
|
||||
proxy,
|
||||
bypassTLSCertificateValidation,
|
||||
_mtlsConfigs,
|
||||
tempFilePath,
|
||||
));
|
||||
if (_isolateSendPorts.keys.contains(task)) {
|
||||
// if already registered with null value, cancel immediately
|
||||
sendPort.send('cancel');
|
||||
}
|
||||
// store the isolate's sendPort so we can send it messages for
|
||||
// cancellation, and for managing parallel downloads
|
||||
_isolateSendPorts[task] = sendPort;
|
||||
// listen for messages sent back from the isolate, until 'done'
|
||||
// note that the task sent by the isolate may have changed. Therefore, we
|
||||
// use updatedTask instead of task from here on
|
||||
while (await messagesFromIsolate.hasNext) {
|
||||
final message = await messagesFromIsolate.next;
|
||||
switch (message) {
|
||||
case 'done':
|
||||
receivePort.close();
|
||||
|
||||
case (
|
||||
'statusUpdate',
|
||||
final Task updatedTask,
|
||||
final TaskStatus status,
|
||||
final TaskException? exception,
|
||||
final String? responseBody,
|
||||
final Map<String, String>? responseHeaders,
|
||||
final int? responseCode,
|
||||
final String? mimeType,
|
||||
final String? charSet,
|
||||
):
|
||||
final taskStatusUpdate = TaskStatusUpdate(
|
||||
updatedTask,
|
||||
status,
|
||||
exception,
|
||||
responseBody,
|
||||
responseHeaders,
|
||||
responseCode,
|
||||
mimeType,
|
||||
charSet,
|
||||
);
|
||||
if (updatedTask.group != BaseDownloader.chunkGroup) {
|
||||
if (status.isFinalState) {
|
||||
_remove(updatedTask);
|
||||
}
|
||||
processStatusUpdate(taskStatusUpdate);
|
||||
} else {
|
||||
_parallelTaskSendPort(Chunk.getParentTaskId(updatedTask))
|
||||
?.send(taskStatusUpdate);
|
||||
}
|
||||
|
||||
case (
|
||||
'progressUpdate',
|
||||
final Task updatedTask,
|
||||
final double progress,
|
||||
final int expectedFileSize,
|
||||
final double downloadSpeed,
|
||||
final Duration timeRemaining,
|
||||
):
|
||||
final taskProgressUpdate = TaskProgressUpdate(
|
||||
updatedTask,
|
||||
progress,
|
||||
expectedFileSize,
|
||||
downloadSpeed,
|
||||
timeRemaining,
|
||||
);
|
||||
if (updatedTask.group != BaseDownloader.chunkGroup) {
|
||||
processProgressUpdate(taskProgressUpdate);
|
||||
} else {
|
||||
_parallelTaskSendPort(Chunk.getParentTaskId(updatedTask))
|
||||
?.send(taskProgressUpdate);
|
||||
}
|
||||
|
||||
case ('taskCanResume', final bool taskCanResume):
|
||||
setCanResume(task, taskCanResume);
|
||||
|
||||
case (
|
||||
'resumeData',
|
||||
final String data,
|
||||
final int requiredStartByte,
|
||||
final String? eTag,
|
||||
):
|
||||
try {
|
||||
await setResumeData(
|
||||
ResumeData(task, data, requiredStartByte, eTag),
|
||||
);
|
||||
} catch (e) {
|
||||
_log.warning('Failed to store resume data: $e');
|
||||
}
|
||||
|
||||
// from [ParallelDownloadTask]
|
||||
case ('enqueueChild', final DownloadTask childTask):
|
||||
await FileDownloader().enqueue(childTask);
|
||||
|
||||
// from [ParallelDownloadTask]
|
||||
case ('cancelTasksWithId', final List<String> taskIds):
|
||||
await FileDownloader().cancelTasksWithIds(taskIds);
|
||||
|
||||
// from [ParallelDownloadTask]
|
||||
case ('pauseTasks', final List<DownloadTask> tasks):
|
||||
for (final chunkTask in tasks) {
|
||||
await FileDownloader().pause(chunkTask);
|
||||
}
|
||||
|
||||
case ('log', final String logMessage):
|
||||
_log.finest(logMessage);
|
||||
|
||||
default:
|
||||
_log.warning(
|
||||
'Received message with unknown type '
|
||||
'$message from Isolate',
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
receivePort.close();
|
||||
errorPort.close();
|
||||
_isolateSendPorts.remove(task);
|
||||
}
|
||||
}
|
||||
|
||||
// intercept the status and progress updates for tasks that are 'chunks', i.e.
|
||||
// part of a [ParallelDownloadTask]. Updates for these tasks are sent to the
|
||||
// isolate running the [ParallelDownloadTask] instead
|
||||
|
||||
@override
|
||||
void processStatusUpdate(TaskStatusUpdate update) {
|
||||
// Regular update if task's group is not chunkGroup
|
||||
if (update.task.group != FileDownloader.chunkGroup) {
|
||||
return super.processStatusUpdate(update);
|
||||
}
|
||||
// If chunkGroup, send update to task's parent isolate.
|
||||
// The task's metadata contains taskId of parent
|
||||
_parallelTaskSendPort(Chunk.getParentTaskId(update.task))?.send(update);
|
||||
}
|
||||
|
||||
@override
|
||||
void processProgressUpdate(TaskProgressUpdate update) {
|
||||
// Regular update if task's group is not chunkGroup
|
||||
if (update.task.group != FileDownloader.chunkGroup) {
|
||||
return super.processProgressUpdate(update);
|
||||
}
|
||||
// If chunkGroup, send update to task's parent isolate.
|
||||
// The task's metadata contains taskId of parent
|
||||
_parallelTaskSendPort(Chunk.getParentTaskId(update.task))?.send(update);
|
||||
}
|
||||
|
||||
/// Return the [SendPort] for the [ParallelDownloadTask] represented by [taskId]
|
||||
/// or null if not a [ParallelDownloadTask] or not found
|
||||
SendPort? _parallelTaskSendPort(String taskId) => _isolateSendPorts.entries
|
||||
.firstWhereOrNull(
|
||||
(entry) =>
|
||||
entry.key is ParallelDownloadTask && entry.key.taskId == taskId,
|
||||
)
|
||||
?.value;
|
||||
|
||||
@override
|
||||
Future<int> reset(String group) async {
|
||||
final retryAndPausedTaskCount = await super.reset(group);
|
||||
final inQueueIds = _queue.unorderedElements
|
||||
.where((task) => task.group == group)
|
||||
.map((task) => task.taskId);
|
||||
final runningIds = _running
|
||||
.where((task) => task.group == group)
|
||||
.map((task) => task.taskId);
|
||||
final taskIds = [...inQueueIds, ...runningIds];
|
||||
if (taskIds.isNotEmpty) {
|
||||
await cancelTasksWithIds(taskIds);
|
||||
}
|
||||
return retryAndPausedTaskCount + taskIds.length;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Task>> allTasks(
|
||||
String group,
|
||||
bool includeTasksWaitingToRetry,
|
||||
allGroups,
|
||||
) async {
|
||||
final retryAndPausedTasks = await super.allTasks(
|
||||
group,
|
||||
includeTasksWaitingToRetry,
|
||||
allGroups,
|
||||
);
|
||||
if (allGroups) {
|
||||
return [...retryAndPausedTasks, ..._queue.unorderedElements, ..._running];
|
||||
}
|
||||
final inQueue = _queue.unorderedElements.where(
|
||||
(task) => task.group == group,
|
||||
);
|
||||
final running = _running.where((task) => task.group == group);
|
||||
return [...retryAndPausedTasks, ...inQueue, ...running];
|
||||
}
|
||||
|
||||
/// Cancels ongoing platform tasks whose taskId is in the list provided
|
||||
///
|
||||
/// Returns true if all cancellations were successful
|
||||
@override
|
||||
Future<bool> cancelPlatformTasksWithIds(List<String> taskIds) async {
|
||||
final inQueue = _queue.unorderedElements
|
||||
.where((task) => taskIds.contains(task.taskId))
|
||||
.toList(growable: false);
|
||||
for (final task in inQueue) {
|
||||
processStatusUpdate(TaskStatusUpdate(task, TaskStatus.canceled));
|
||||
_remove(task);
|
||||
}
|
||||
final running = _running.where((task) => taskIds.contains(task.taskId));
|
||||
for (final task in running) {
|
||||
final sendPort = _isolateSendPorts[task];
|
||||
if (sendPort != null) {
|
||||
sendPort.send('cancel');
|
||||
_isolateSendPorts.remove(task);
|
||||
} else {
|
||||
// register task for cancellation even if sendPort does not yet exist:
|
||||
// this will lead to immediate cancellation when the Isolate starts
|
||||
_isolateSendPorts[task] = null;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Task?> taskForId(String taskId) async {
|
||||
final task = await super.taskForId(taskId);
|
||||
if (task != null) {
|
||||
return task;
|
||||
}
|
||||
try {
|
||||
return _running.where((task) => task.taskId == taskId).first;
|
||||
} on StateError {
|
||||
try {
|
||||
return _queue.unorderedElements
|
||||
.where((task) => task.taskId == taskId)
|
||||
.first;
|
||||
} on StateError {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> pause(Task task) async {
|
||||
final sendPort = _isolateSendPorts[task];
|
||||
if (sendPort != null) {
|
||||
sendPort.send('pause');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<bool>> pauseTaskList(Iterable<Task> tasksToPause) async {
|
||||
final pauseCalls = tasksToPause.map((task) => pause(task));
|
||||
return Future.wait(pauseCalls);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> resume(Task task) async {
|
||||
if (await super.resume(task)) {
|
||||
task = awaitTasks.containsKey(task)
|
||||
? awaitTasks.keys.firstWhere(
|
||||
(awaitTask) => awaitTask.taskId == task.taskId,
|
||||
)
|
||||
: task;
|
||||
// Enqueue starts _executeTask, which consumes the persisted resume data.
|
||||
// Retain the parent snapshot before that worker can remove it.
|
||||
final resumeData = task is ParallelDownloadTask
|
||||
? await getResumeData(task.taskId)
|
||||
: null;
|
||||
_resume.add(task);
|
||||
if (await enqueue(task)) {
|
||||
if (task is ParallelDownloadTask) {
|
||||
if (resumeData == null) {
|
||||
return false;
|
||||
}
|
||||
return resumeChunkTasks(task, resumeData);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, String>> popUndeliveredData(Undelivered dataType) =>
|
||||
Future.value({});
|
||||
|
||||
@override
|
||||
Future<String?> moveToSharedStorage(
|
||||
String filePath,
|
||||
SharedStorage destination,
|
||||
String directory,
|
||||
String? mimeType, {
|
||||
bool asUriString = false,
|
||||
}) async {
|
||||
final fileUri = Uri.tryParse(filePath);
|
||||
if (fileUri case Uri(scheme: 'file')) {
|
||||
filePath = fileUri.toFilePath(
|
||||
windows: defaultTargetPlatform == TargetPlatform.windows,
|
||||
);
|
||||
}
|
||||
final destDirectoryPath = await getDestinationDirectoryPath(
|
||||
destination,
|
||||
directory,
|
||||
);
|
||||
if (destDirectoryPath == null) {
|
||||
return null;
|
||||
}
|
||||
if (!await Directory(destDirectoryPath).exists()) {
|
||||
await Directory(destDirectoryPath).create(recursive: true);
|
||||
}
|
||||
final fileName = path.basename(filePath);
|
||||
final destFilePath = path.join(destDirectoryPath, fileName);
|
||||
try {
|
||||
await File(filePath).rename(destFilePath);
|
||||
} on FileSystemException catch (e) {
|
||||
// rename fails across volumes (e.g. EXDEV), so fall back to copy + delete
|
||||
_log.fine('Could not rename $filePath ($e), trying copy and delete');
|
||||
try {
|
||||
await File(filePath).copy(destFilePath);
|
||||
} on FileSystemException catch (e) {
|
||||
_log.warning('Error moving $filePath to shared storage: $e');
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await File(filePath).delete();
|
||||
} on FileSystemException catch (e) {
|
||||
_log.warning('Could not delete $filePath after copying: $e');
|
||||
}
|
||||
}
|
||||
return asUriString ? Uri.file(destFilePath).toString() : destFilePath;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> pathInSharedStorage(
|
||||
String filePath,
|
||||
SharedStorage destination,
|
||||
String directory, {
|
||||
bool asUriString = false,
|
||||
}) async {
|
||||
final destDirectoryPath = await getDestinationDirectoryPath(
|
||||
destination,
|
||||
directory,
|
||||
);
|
||||
if (destDirectoryPath == null) {
|
||||
return null;
|
||||
}
|
||||
final fileName = path.basename(filePath);
|
||||
final destFilePath = path.join(destDirectoryPath, fileName);
|
||||
return asUriString ? Uri.file(destFilePath).toString() : destFilePath;
|
||||
}
|
||||
|
||||
/// Returns the path of the destination directory in shared storage, or null
|
||||
///
|
||||
/// Only the .Downloads directory is supported on desktop.
|
||||
/// The [directory] is appended to the base Downloads directory.
|
||||
/// The directory at the returned path is not guaranteed to exist.
|
||||
Future<String?> getDestinationDirectoryPath(
|
||||
SharedStorage destination,
|
||||
String directory,
|
||||
) async {
|
||||
if (destination != SharedStorage.downloads) {
|
||||
_log.finer('Desktop only supports .downloads destination');
|
||||
return null;
|
||||
}
|
||||
final downloadsDirectory = await getDownloadsDirectory();
|
||||
if (downloadsDirectory == null) {
|
||||
_log.warning('Could not obtain downloads directory');
|
||||
return null;
|
||||
}
|
||||
// remove leading and trailing slashes from [directory]
|
||||
var cleanDirectory = directory.replaceAll(RegExp(r'^/+'), '');
|
||||
cleanDirectory = cleanDirectory.replaceAll(RegExp(r'/$'), '');
|
||||
return cleanDirectory.isEmpty
|
||||
? downloadsDirectory.path
|
||||
: path.join(downloadsDirectory.path, cleanDirectory);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> openFile(Task? task, String? filePath, String? mimeType) async {
|
||||
final executable = defaultTargetPlatform == TargetPlatform.linux
|
||||
? 'xdg-open'
|
||||
: defaultTargetPlatform == TargetPlatform.macOS
|
||||
? 'open'
|
||||
: 'start';
|
||||
filePath ??= await task!.filePath();
|
||||
if (!await File(filePath).exists()) {
|
||||
_log.fine('File to open does not exist: $filePath');
|
||||
return false;
|
||||
}
|
||||
// On Windows, 'start' treats its first quoted argument as the window
|
||||
// title, so pass an empty title before the (possibly quoted) filePath
|
||||
final result = defaultTargetPlatform == TargetPlatform.windows
|
||||
? await Process.run('cmd', ['/c', 'start', '', filePath])
|
||||
: await Process.run(executable, [filePath], runInShell: true);
|
||||
if (result.exitCode != 0) {
|
||||
_log.fine(
|
||||
'openFile command $executable returned exit code ${result.exitCode}',
|
||||
);
|
||||
}
|
||||
return result.exitCode == 0;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Duration> getTaskTimeout() => Future.value(const Duration(days: 1));
|
||||
|
||||
@override
|
||||
Future<void> setForceFailPostOnBackgroundChannel(bool value) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> testSuggestedFilename(
|
||||
DownloadTask task,
|
||||
String contentDisposition,
|
||||
) async {
|
||||
final h = contentDisposition.isNotEmpty
|
||||
? {'Content-disposition': contentDisposition}
|
||||
: <String, String>{};
|
||||
final t = await taskWithSuggestedFilename(task, h, false);
|
||||
return t.filename;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic platformConfig({
|
||||
dynamic globalConfig,
|
||||
dynamic androidConfig,
|
||||
dynamic iOSConfig,
|
||||
dynamic desktopConfig,
|
||||
}) => desktopConfig;
|
||||
|
||||
@override
|
||||
Future<(String, String)> configureItem((String, dynamic) configItem) async {
|
||||
switch (configItem) {
|
||||
case (Config.requestTimeout, final Duration? duration):
|
||||
requestTimeout = duration;
|
||||
|
||||
case (Config.proxy, (final String address, final int port)):
|
||||
proxy = {'address': address, 'port': port};
|
||||
|
||||
case (Config.proxy, false):
|
||||
proxy = {};
|
||||
|
||||
case (Config.bypassTLSCertificateValidation, final bool bypass):
|
||||
bypassTLSCertificateValidation = bypass;
|
||||
|
||||
case (Config.mTLS, final MTLSConfig config):
|
||||
mtlsConfig = config;
|
||||
|
||||
case (Config.mTLS, false):
|
||||
case (Config.mTLS, null):
|
||||
resetMtlsConfig();
|
||||
|
||||
case (
|
||||
Config.holdingQueue,
|
||||
(
|
||||
final int? maxConcurrentParam,
|
||||
final int? maxConcurrentByHostParam,
|
||||
final int? maxConcurrentByGroupParam,
|
||||
),
|
||||
):
|
||||
maxConcurrent = maxConcurrentParam ?? 10;
|
||||
maxConcurrentByHost = maxConcurrentByHostParam ?? unlimited;
|
||||
maxConcurrentByGroup = maxConcurrentByGroupParam ?? unlimited;
|
||||
|
||||
case (Config.holdingQueue, Config.never):
|
||||
case (Config.holdingQueue, false):
|
||||
maxConcurrent = unlimited;
|
||||
maxConcurrentByHost = unlimited;
|
||||
maxConcurrentByGroup = unlimited;
|
||||
|
||||
case (Config.skipExistingFiles, final int value):
|
||||
_skipExistingFiles = value;
|
||||
|
||||
case (Config.skipExistingFiles, Config.never):
|
||||
case (Config.skipExistingFiles, false):
|
||||
_skipExistingFiles = -1;
|
||||
|
||||
case (Config.skipExistingFiles, Config.always):
|
||||
case (Config.skipExistingFiles, true):
|
||||
_skipExistingFiles = 0;
|
||||
|
||||
case (Config.tempFilePath, final String path):
|
||||
tempFilePath = (path.isNotEmpty && path != Config.never) ? path : null;
|
||||
|
||||
case (Config.tempFilePath, null):
|
||||
case (Config.tempFilePath, false):
|
||||
tempFilePath = null;
|
||||
|
||||
default:
|
||||
return (
|
||||
configItem.$1,
|
||||
'not implemented',
|
||||
); // this method did not process this configItem
|
||||
}
|
||||
return (configItem.$1, ''); // normal result
|
||||
}
|
||||
|
||||
/// Sets requestTimeout and recreates HttpClient
|
||||
static set requestTimeout(Duration? value) {
|
||||
_requestTimeout = value;
|
||||
_recreateClient();
|
||||
}
|
||||
|
||||
static Duration? get requestTimeout => _requestTimeout;
|
||||
|
||||
/// Sets proxy and recreates HttpClient
|
||||
///
|
||||
/// Value must be dict containing 'address' and 'port'
|
||||
/// or empty for no proxy
|
||||
static set proxy(Map<String, dynamic> value) {
|
||||
_proxy = value;
|
||||
_recreateClient();
|
||||
}
|
||||
|
||||
static Map<String, dynamic> get proxy => _proxy;
|
||||
|
||||
/// Set or resets bypass for TLS certificate validation
|
||||
static set bypassTLSCertificateValidation(bool value) {
|
||||
_bypassTLSCertificateValidation = value;
|
||||
_recreateClient();
|
||||
}
|
||||
|
||||
static bool get bypassTLSCertificateValidation =>
|
||||
_bypassTLSCertificateValidation;
|
||||
|
||||
/// Set or reset an [MTLSConfig]
|
||||
static set mtlsConfig(MTLSConfig? config) {
|
||||
if (config == null || config.isReset) {
|
||||
resetMtlsConfig(config?.host);
|
||||
} else {
|
||||
final configHost = config.host?.toLowerCase();
|
||||
_mtlsConfigs.removeWhere((c) => c.host?.toLowerCase() == configHost);
|
||||
_mtlsConfigs.add(config);
|
||||
_recreateClient();
|
||||
}
|
||||
}
|
||||
|
||||
/// Resets mTLS configuration for [host] or all hosts if [host] is null
|
||||
static void resetMtlsConfig([String? host]) {
|
||||
if (host == null) {
|
||||
_mtlsConfigs.clear();
|
||||
} else {
|
||||
final normHost = host.toLowerCase();
|
||||
_mtlsConfigs.removeWhere((c) => c.host?.toLowerCase() == normHost);
|
||||
}
|
||||
_recreateClient();
|
||||
}
|
||||
|
||||
/// Returns active mTLS configurations
|
||||
static List<MTLSConfig> get mtlsConfigs => List.unmodifiable(_mtlsConfigs);
|
||||
|
||||
/// Sets all mTLS configurations
|
||||
static set mtlsConfigs(List<MTLSConfig> configs) {
|
||||
_mtlsConfigs = List.from(configs);
|
||||
_recreateClient();
|
||||
}
|
||||
|
||||
/// Set the HTTP Client to use, with the given parameters
|
||||
///
|
||||
/// This is a convenience method, bundling the [requestTimeout],
|
||||
/// [proxy], [bypassTLSCertificateValidation] and [mtlsConfigs]
|
||||
static void setHttpClient(
|
||||
Duration? requestTimeout,
|
||||
Map<String, dynamic> proxy,
|
||||
bool bypassTLSCertificateValidation, [
|
||||
List<MTLSConfig>? mtlsConfigs,
|
||||
]) {
|
||||
_requestTimeout = requestTimeout;
|
||||
_proxy = proxy;
|
||||
_bypassTLSCertificateValidation = bypassTLSCertificateValidation;
|
||||
if (mtlsConfigs != null) {
|
||||
_mtlsConfigs = List.from(mtlsConfigs);
|
||||
}
|
||||
_recreateClient();
|
||||
}
|
||||
|
||||
/// Returns an [http.Client] configured for the given [url].
|
||||
static http.Client httpClientForUrl(String? url) {
|
||||
final host = url != null ? Uri.tryParse(url)?.host : null;
|
||||
return httpClientForHost(host);
|
||||
}
|
||||
|
||||
/// Returns an [http.Client] configured for the given [host].
|
||||
static http.Client httpClientForHost(String? host) {
|
||||
if (_mtlsConfigs.isEmpty) {
|
||||
return _defaultClient ??= _createRawClient(null);
|
||||
}
|
||||
|
||||
final normHost = host?.toLowerCase();
|
||||
final matchedConfig =
|
||||
(normHost != null
|
||||
? _mtlsConfigs.firstWhereOrNull(
|
||||
(c) => c.host?.toLowerCase() == normHost && c.hasCredentials,
|
||||
)
|
||||
: null) ??
|
||||
_mtlsConfigs.firstWhereOrNull(
|
||||
(c) => c.host == null && c.hasCredentials,
|
||||
);
|
||||
|
||||
if (matchedConfig == null) {
|
||||
return _defaultClient ??= _createRawClient(null);
|
||||
}
|
||||
|
||||
final cacheKey = matchedConfig.host?.toLowerCase() ?? '*';
|
||||
return _clientsCache.putIfAbsent(
|
||||
cacheKey,
|
||||
() => _createRawClient(matchedConfig),
|
||||
);
|
||||
}
|
||||
|
||||
static const defaultMaxRedirects = 10;
|
||||
|
||||
/// Default HTTP client getter for backward compatibility
|
||||
static http.Client get httpClient => httpClientForHost(null);
|
||||
|
||||
/// Sets the default HTTP client (primarily for testing)
|
||||
static set httpClient(http.Client client) {
|
||||
_defaultClient = client is _RedirectClient
|
||||
? client
|
||||
: _RedirectClient(client);
|
||||
}
|
||||
|
||||
static http.Client _createRawClient(MTLSConfig? mtlsConfig) {
|
||||
SecurityContext? securityContext;
|
||||
if (mtlsConfig != null && mtlsConfig.hasCredentials) {
|
||||
_log.finest(
|
||||
'Creating HTTP client with mTLS configuration for host: ${mtlsConfig.host ?? "all hosts"}',
|
||||
);
|
||||
securityContext = SecurityContext(withTrustedRoots: true);
|
||||
mtlsConfig.applyToSecurityContext(securityContext);
|
||||
}
|
||||
final client = HttpClient(context: securityContext);
|
||||
client.connectionTimeout = requestTimeout;
|
||||
client.findProxy = proxy.isNotEmpty
|
||||
? (_) => 'PROXY ${_proxy['address']}:${_proxy['port']}'
|
||||
: null;
|
||||
client.badCertificateCallback =
|
||||
bypassTLSCertificateValidation && !kReleaseMode
|
||||
? (X509Certificate cert, String host, int port) => true
|
||||
: null;
|
||||
return _RedirectClient(IOClient(client));
|
||||
}
|
||||
|
||||
/// Recreates the HTTP client instances used for Requests and isolate downloads/uploads
|
||||
static void _recreateClient() {
|
||||
for (final client in _clientsCache.values) {
|
||||
client.close();
|
||||
}
|
||||
_clientsCache.clear();
|
||||
_defaultClient?.close();
|
||||
_defaultClient = null;
|
||||
if (bypassTLSCertificateValidation) {
|
||||
if (kReleaseMode) {
|
||||
throw ArgumentError(
|
||||
'You cannot bypass certificate validation in release mode',
|
||||
);
|
||||
} else {
|
||||
_log.warning(
|
||||
'TLS certificate validation is bypassed. This is insecure and cannot be '
|
||||
'done in release mode',
|
||||
);
|
||||
}
|
||||
}
|
||||
_log.finest(
|
||||
'Using HTTP client with requestTimeout $_requestTimeout, proxy $_proxy, TLSCertificateBypass = $bypassTLSCertificateValidation, mTLS configs = ${_mtlsConfigs.length}',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void destroy() {
|
||||
super.destroy();
|
||||
_queue.clear();
|
||||
_running.clear();
|
||||
_isolateSendPorts.clear();
|
||||
}
|
||||
|
||||
/// Remove all references to [task]
|
||||
void _remove(Task task) {
|
||||
_queue.remove(task);
|
||||
_running.remove(task);
|
||||
_isolateSendPorts.remove(task);
|
||||
}
|
||||
}
|
||||
|
||||
/// The default maximum number of redirects to follow for HTTP requests (10).
|
||||
///
|
||||
/// On Desktop platforms (macOS, Windows, Linux) and for [FileDownloader.request],
|
||||
/// up to 10 redirects are followed. Mobile platforms follow their native defaults
|
||||
/// (iOS allows up to 16, Android allows up to 20).
|
||||
const defaultMaxRedirects = DesktopDownloader.defaultMaxRedirects;
|
||||
|
||||
/// Wrapper around [http.Client] that raises [http.BaseRequest.maxRedirects] to
|
||||
/// [defaultMaxRedirects] if it is still at the Dart default of 5.
|
||||
final class _RedirectClient extends http.BaseClient {
|
||||
final http.Client _inner;
|
||||
|
||||
_RedirectClient(this._inner);
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) {
|
||||
if (request.maxRedirects == 5) {
|
||||
request.maxRedirects = defaultMaxRedirects;
|
||||
}
|
||||
return _inner.send(request);
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {
|
||||
_inner.close();
|
||||
super.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../exceptions.dart';
|
||||
import '../models.dart';
|
||||
import '../task.dart';
|
||||
import '../utils.dart';
|
||||
import 'desktop_downloader.dart';
|
||||
import 'isolate.dart';
|
||||
|
||||
var taskRangeStartByte = 0; // Start of the Task's download range
|
||||
String? eTagHeader;
|
||||
late DownloadTask downloadTask; // global because filename may change
|
||||
|
||||
/// Execute the download task
|
||||
///
|
||||
/// Sends updates via the [sendPort] and can be commanded to cancel/pause via
|
||||
/// the [messagesToIsolate] queue
|
||||
Future<void> doDownloadTask(
|
||||
DownloadTask task,
|
||||
ResumeData? resumeData,
|
||||
bool isResume,
|
||||
Duration requestTimeout,
|
||||
String? tempFilePathConfig,
|
||||
SendPort sendPort,
|
||||
) async {
|
||||
// use downloadTask from here on as a 'global' variable in this isolate,
|
||||
// as we may change the filename of the task
|
||||
downloadTask = task;
|
||||
var filePath = await downloadTask.filePath();
|
||||
// tempFilePath is taken from [resumeDataString] if this is a resuming task.
|
||||
// Otherwise, it is a generated full path to the temp directory
|
||||
final tempFilePath = isResume && resumeData != null
|
||||
? resumeData.tempFilepath
|
||||
: p.join(
|
||||
tempFilePathConfig ?? (await getTemporaryDirectory()).path,
|
||||
'com.bbflight.background_downloader${Random().nextInt(1 << 32).toString()}',
|
||||
);
|
||||
final requiredStartByte =
|
||||
resumeData?.requiredStartByte ?? 0; // start for resume
|
||||
final eTag = resumeData?.eTag;
|
||||
isResume =
|
||||
isResume &&
|
||||
await determineIfResumeIsPossible(tempFilePath, requiredStartByte);
|
||||
final client = DesktopDownloader.httpClientForUrl(downloadTask.url);
|
||||
final request = http.Request(
|
||||
downloadTask.httpRequestMethod,
|
||||
Uri.parse(downloadTask.url),
|
||||
);
|
||||
request.headers.addAll(downloadTask.headers);
|
||||
request.maxRedirects = defaultMaxRedirects;
|
||||
request.persistentConnection = false;
|
||||
if (isResume) {
|
||||
final taskRangeHeader = downloadTask.headers['Range'] ?? '';
|
||||
final taskRange = parseRange(taskRangeHeader);
|
||||
taskRangeStartByte = taskRange.$1;
|
||||
final resumeRange = (taskRangeStartByte + requiredStartByte, taskRange.$2);
|
||||
final newRangeString = 'bytes=${resumeRange.$1}-${resumeRange.$2 ?? ""}';
|
||||
request.headers['Range'] = newRangeString;
|
||||
}
|
||||
switch (downloadTask.postBody) {
|
||||
case final Uint8List bytes:
|
||||
request.bodyBytes = bytes;
|
||||
case final String post:
|
||||
request.body = post;
|
||||
}
|
||||
var resultStatus = TaskStatus.failed;
|
||||
try {
|
||||
final response = await client.send(request).timeout(requestTimeout);
|
||||
if (!isCanceled) {
|
||||
eTagHeader = response.headers['etag'] ?? response.headers['ETag'];
|
||||
final acceptRangesHeader = response.headers['accept-ranges'];
|
||||
final serverAcceptsRanges =
|
||||
acceptRangesHeader == 'bytes' || response.statusCode == 206;
|
||||
var taskCanResume = false;
|
||||
if (downloadTask.allowPause) {
|
||||
// determine if this task can be paused
|
||||
taskCanResume = serverAcceptsRanges;
|
||||
sendPort.send(('taskCanResume', taskCanResume));
|
||||
}
|
||||
isResume =
|
||||
isResume && response.statusCode == 206; // confirm resume response
|
||||
if (isResume && (eTagHeader != eTag || eTag?.startsWith('W/') == true)) {
|
||||
throw TaskException('Cannot resume: ETag is not identical, or is weak');
|
||||
}
|
||||
if (!downloadTask.hasFilename) {
|
||||
downloadTask = await taskWithSuggestedFilename(
|
||||
downloadTask,
|
||||
response.headers,
|
||||
true,
|
||||
);
|
||||
// update the filePath by replacing the last segment with the new filename
|
||||
filePath = p.join(p.dirname(filePath), downloadTask.filename);
|
||||
log.finest(
|
||||
'Suggested filename for taskId ${task.taskId}: ${task.filename}',
|
||||
);
|
||||
}
|
||||
responseHeaders = response.headers;
|
||||
responseStatusCode = response.statusCode;
|
||||
extractContentType(response.headers);
|
||||
if (okResponses.contains(response.statusCode)) {
|
||||
resultStatus = await processOkDownloadResponse(
|
||||
filePath,
|
||||
tempFilePath,
|
||||
serverAcceptsRanges,
|
||||
taskCanResume,
|
||||
isResume,
|
||||
requestTimeout,
|
||||
response,
|
||||
sendPort,
|
||||
);
|
||||
} else {
|
||||
// not an OK response
|
||||
responseBody = await responseContent(response);
|
||||
if (response.statusCode == 404) {
|
||||
resultStatus = TaskStatus.notFound;
|
||||
} else {
|
||||
taskException = TaskHttpException(
|
||||
responseBody?.isNotEmpty == true
|
||||
? responseBody!
|
||||
: response.reasonPhrase ?? 'Invalid HTTP Request',
|
||||
response.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logError(downloadTask, e.toString());
|
||||
setTaskError(e);
|
||||
}
|
||||
if (isCanceled) {
|
||||
// cancellation overrides other results
|
||||
resultStatus = TaskStatus.canceled;
|
||||
}
|
||||
processStatusUpdateInIsolate(downloadTask, resultStatus, sendPort);
|
||||
}
|
||||
|
||||
/// Return true if resume is possible
|
||||
///
|
||||
/// Confirms that file at [tempFilePath] exists and its length equals
|
||||
/// [requiredStartByte]
|
||||
Future<bool> determineIfResumeIsPossible(
|
||||
String tempFilePath,
|
||||
int requiredStartByte,
|
||||
) async {
|
||||
if (File(tempFilePath).existsSync()) {
|
||||
if (await File(tempFilePath).length() == requiredStartByte) {
|
||||
return true;
|
||||
} else {
|
||||
log.fine('Partially downloaded file is corrupted, resume not possible');
|
||||
}
|
||||
} else {
|
||||
log.fine('Partially downloaded file not available, resume not possible');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Process response with valid response code
|
||||
///
|
||||
/// Performs the actual bytes transfer from response to a temp file,
|
||||
/// and handles the result of the transfer:
|
||||
/// - .complete -> copy temp to final file location
|
||||
/// - .failed -> delete temp file
|
||||
/// - .paused -> post resume information
|
||||
Future<TaskStatus> processOkDownloadResponse(
|
||||
String filePath,
|
||||
String tempFilePath,
|
||||
bool serverAcceptsRanges,
|
||||
bool taskCanResume,
|
||||
bool isResume,
|
||||
Duration requestTimeout,
|
||||
http.StreamedResponse response,
|
||||
SendPort sendPort,
|
||||
) async {
|
||||
// contentLength is extracted from response header, and if not available
|
||||
// we attempt to extract from [Task.headers], allowing developer to
|
||||
// set the content length if already known
|
||||
final contentLength = getContentLength(response.headers, downloadTask);
|
||||
isResume = isResume && response.statusCode == 206;
|
||||
if (isResume && !await prepareResume(response, tempFilePath)) {
|
||||
deleteTempFile(tempFilePath);
|
||||
return TaskStatus.failed;
|
||||
}
|
||||
var resultStatus = TaskStatus.failed;
|
||||
var actualTempFilePath = tempFilePath;
|
||||
IOSink? outStream;
|
||||
try {
|
||||
// do the actual download
|
||||
try {
|
||||
Directory(p.dirname(actualTempFilePath)).createSync(recursive: true);
|
||||
outStream = File(actualTempFilePath)
|
||||
.openWrite(mode: isResume ? FileMode.append : FileMode.write);
|
||||
} catch (e) {
|
||||
if (!isResume) {
|
||||
// Fallback to the target download directory as a hidden file
|
||||
final targetDir = p.dirname(filePath);
|
||||
Directory(targetDir).createSync(recursive: true);
|
||||
actualTempFilePath = p.join(targetDir, '.${p.basename(tempFilePath)}');
|
||||
log.info(
|
||||
'Standard temporary directory not writeable ($e). '
|
||||
'Falling back to target directory temp file: $actualTempFilePath',
|
||||
);
|
||||
outStream = File(actualTempFilePath).openWrite(mode: FileMode.write);
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
final transferBytesResult = await transferBytes(
|
||||
response.stream,
|
||||
outStream,
|
||||
contentLength,
|
||||
downloadTask,
|
||||
sendPort,
|
||||
requestTimeout,
|
||||
);
|
||||
switch (transferBytesResult) {
|
||||
case .complete:
|
||||
// copy file to destination, creating dirs if needed
|
||||
await outStream.flush();
|
||||
final dirPath = p.dirname(filePath);
|
||||
Directory(dirPath).createSync(recursive: true);
|
||||
File(actualTempFilePath).copySync(filePath);
|
||||
resultStatus = TaskStatus.complete;
|
||||
|
||||
case .canceled:
|
||||
// temp file is deleted in finally, after closing outStream
|
||||
resultStatus = TaskStatus.canceled;
|
||||
|
||||
case .paused:
|
||||
if (taskCanResume) {
|
||||
sendPort.send((
|
||||
'resumeData',
|
||||
actualTempFilePath,
|
||||
bytesTotal + startByte,
|
||||
eTagHeader,
|
||||
));
|
||||
resultStatus = TaskStatus.paused;
|
||||
} else {
|
||||
taskException = TaskResumeException(
|
||||
'Task was paused but cannot resume',
|
||||
);
|
||||
resultStatus = TaskStatus.failed;
|
||||
}
|
||||
|
||||
case .failed:
|
||||
break;
|
||||
|
||||
default:
|
||||
throw ArgumentError('Cannot process $transferBytesResult');
|
||||
}
|
||||
} catch (e) {
|
||||
logError(downloadTask, e.toString());
|
||||
setTaskError(e);
|
||||
} finally {
|
||||
try {
|
||||
try {
|
||||
await outStream?.close();
|
||||
} catch (_) {}
|
||||
if (resultStatus == TaskStatus.failed &&
|
||||
serverAcceptsRanges &&
|
||||
(bytesTotal + startByte > 1 << 20 || isResume)) {
|
||||
// send ResumeData to allow resume after fail
|
||||
sendPort.send((
|
||||
'resumeData',
|
||||
actualTempFilePath,
|
||||
bytesTotal + startByte,
|
||||
eTagHeader,
|
||||
));
|
||||
} else if (resultStatus != TaskStatus.paused &&
|
||||
File(actualTempFilePath).existsSync()) {
|
||||
File(actualTempFilePath).deleteSync();
|
||||
}
|
||||
} catch (e) {
|
||||
logError(
|
||||
downloadTask,
|
||||
'Could not delete temp file $actualTempFilePath: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
return resultStatus;
|
||||
}
|
||||
|
||||
/// Prepare for resume if possible
|
||||
///
|
||||
/// Returns true if task can continue, false if task failed.
|
||||
/// Extracts and parses Range headers, and truncates temp file
|
||||
Future<bool> prepareResume(
|
||||
http.StreamedResponse response,
|
||||
String tempFilePath,
|
||||
) async {
|
||||
final range = response.headers['content-range'];
|
||||
if (range == null) {
|
||||
log.fine('Could not process partial response Content-Range');
|
||||
taskException = TaskResumeException(
|
||||
'Could not process partial response Content-Range',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
final contentRangeRegEx = RegExp(r"(\d+)-(\d+)/(\d+)");
|
||||
final matchResult = contentRangeRegEx.firstMatch(range);
|
||||
if (matchResult == null) {
|
||||
log.fine('Could not process partial response Content-Range $range');
|
||||
taskException = TaskResumeException(
|
||||
'Could not process '
|
||||
'partial response Content-Range $range',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
final start = int.parse(matchResult.group(1) ?? '0');
|
||||
final end = int.parse(matchResult.group(2) ?? '0');
|
||||
final total = int.parse(matchResult.group(3) ?? '0');
|
||||
final tempFile = File(tempFilePath);
|
||||
final tempFileLength = await tempFile.length();
|
||||
log.finest(
|
||||
'Resume start=$start, end=$end of total=$total bytes, tempFile = $tempFileLength bytes',
|
||||
);
|
||||
startByte = start - taskRangeStartByte; // relative to start of range
|
||||
if (startByte > tempFileLength) {
|
||||
log.fine('Offered range not feasible: $range with startByte $startByte');
|
||||
taskException = TaskResumeException(
|
||||
'Offered range not feasible: $range with startByte $startByte',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
final file = await tempFile.open(mode: FileMode.writeOnlyAppend);
|
||||
await file.truncate(startByte);
|
||||
await file.close();
|
||||
} on FileSystemException {
|
||||
log.fine('Could not truncate temp file');
|
||||
taskException = TaskResumeException('Could not truncate temp file');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Delete the temporary file
|
||||
void deleteTempFile(String tempFilePath) async {
|
||||
try {
|
||||
File(tempFilePath).deleteSync();
|
||||
} on FileSystemException {
|
||||
log.fine('Could not delete temp file $tempFilePath');
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract content type from [headers] and set [mimeType] and [charSet]
|
||||
void extractContentType(Map<String, String> headers) {
|
||||
final contentType = headers['content-type'];
|
||||
if (contentType != null) {
|
||||
final regEx = RegExp(r'(.*);\s*charset\s*=(.*)');
|
||||
final match = regEx.firstMatch(contentType);
|
||||
if (match != null) {
|
||||
mimeType = match.group(1);
|
||||
charSet = match.group(2);
|
||||
} else {
|
||||
mimeType = contentType;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:async/async.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import '../exceptions.dart';
|
||||
import '../models.dart';
|
||||
import '../task.dart';
|
||||
import 'data_isolate.dart';
|
||||
import 'desktop_downloader.dart';
|
||||
import 'download_isolate.dart';
|
||||
import 'parallel_download_isolate.dart';
|
||||
import 'upload_isolate.dart';
|
||||
|
||||
/// global variables, unique to this isolate
|
||||
var bytesTotal = 0; // total bytes read in this download session
|
||||
var startByte =
|
||||
0; // starting position within the original range, used for resume
|
||||
var lastProgressUpdateTime = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
var nextProgressUpdateTime = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
var lastProgressUpdate = 0.0;
|
||||
var bytesTotalAtLastProgressUpdate = 0;
|
||||
|
||||
var networkSpeed = 0.0; // in MB/s
|
||||
var isPaused = false;
|
||||
var isCanceled = false;
|
||||
|
||||
// additional parameters for final TaskStatusUpdate
|
||||
TaskException? taskException;
|
||||
String? responseBody;
|
||||
Map<String, String>? responseHeaders;
|
||||
int? responseStatusCode;
|
||||
String? mimeType; // derived from Content-Type header
|
||||
String? charSet; // derived from Content-Type header
|
||||
|
||||
// logging from isolate is always 'FINEST', as it is sent to
|
||||
// the [DesktopDownloader] for processing
|
||||
final log = Logger('FileDownloader');
|
||||
|
||||
/// Do the task, sending messages back to the main isolate via [sendPort]
|
||||
///
|
||||
/// The first message sent back is a [ReceivePort] that is the command port
|
||||
/// for the isolate. The first command must be the arguments: task and filePath.
|
||||
Future<void> doTask((RootIsolateToken, SendPort) isolateArguments) async {
|
||||
final (rootIsolateToken, sendPort) = isolateArguments;
|
||||
BackgroundIsolateBinaryMessenger.ensureInitialized(rootIsolateToken);
|
||||
final receivePort = ReceivePort();
|
||||
// send the receive port back to the main Isolate
|
||||
sendPort.send(receivePort.sendPort);
|
||||
final messagesToIsolate = StreamQueue<dynamic>(receivePort);
|
||||
// get the arguments list and parse each argument
|
||||
final (
|
||||
Task originalTask,
|
||||
ResumeData? resumeData,
|
||||
bool isResume,
|
||||
Duration? requestTimeout,
|
||||
Map<String, dynamic> proxy,
|
||||
bool bypassTLSCertificateValidation,
|
||||
List<MTLSConfig> mtlsConfigs,
|
||||
String? tempFilePathConfig,
|
||||
) = await messagesToIsolate.next;
|
||||
DesktopDownloader.setHttpClient(
|
||||
requestTimeout,
|
||||
proxy,
|
||||
bypassTLSCertificateValidation,
|
||||
mtlsConfigs,
|
||||
);
|
||||
Logger.root.level = Level.ALL;
|
||||
Logger.root.onRecord.listen((LogRecord rec) {
|
||||
if (kDebugMode) {
|
||||
sendPort.send(('log', (rec.message)));
|
||||
}
|
||||
});
|
||||
// process native callbacks beforeTaskStart, onTaskStart and onAuth
|
||||
final statusUpdate = await originalTask.options?.beforeTaskStartCallBack
|
||||
?.call(originalTask);
|
||||
if (statusUpdate != null) {
|
||||
log.fine(
|
||||
'TaskId ${originalTask.taskId} interrupted by beforeTaskStart callback',
|
||||
);
|
||||
// set global vars for final TaskStatusUpdate
|
||||
taskException = statusUpdate.exception;
|
||||
responseBody = statusUpdate.responseBody;
|
||||
responseHeaders = statusUpdate.responseHeaders;
|
||||
responseStatusCode = statusUpdate.responseStatusCode;
|
||||
processStatusUpdateInIsolate(originalTask, statusUpdate.status, sendPort);
|
||||
return;
|
||||
}
|
||||
final task = await getModifiedTask(
|
||||
originalTask,
|
||||
); // processes onStart and onAuth
|
||||
// start listener/processor for incoming messages
|
||||
unawaited(listenToIncomingMessages(task, messagesToIsolate, sendPort));
|
||||
processStatusUpdateInIsolate(task, TaskStatus.running, sendPort);
|
||||
if (!isResume) {
|
||||
processProgressUpdateInIsolate(task, 0.0, sendPort);
|
||||
}
|
||||
if (task.retriesRemaining < 0) {
|
||||
const message = 'Task has negative retries remaining';
|
||||
logError(task, message);
|
||||
taskException = TaskException(message);
|
||||
processStatusUpdateInIsolate(task, TaskStatus.failed, sendPort);
|
||||
} else {
|
||||
// allow immediate cancel message to come through
|
||||
await Future.delayed(const Duration(milliseconds: 0));
|
||||
await switch (task) {
|
||||
ParallelDownloadTask() => doParallelDownloadTask(
|
||||
task,
|
||||
resumeData,
|
||||
isResume,
|
||||
requestTimeout ?? const Duration(seconds: 60),
|
||||
sendPort,
|
||||
),
|
||||
DownloadTask() => doDownloadTask(
|
||||
task,
|
||||
resumeData,
|
||||
isResume,
|
||||
requestTimeout ?? const Duration(seconds: 60),
|
||||
tempFilePathConfig,
|
||||
sendPort,
|
||||
),
|
||||
UploadTask() => doUploadTask(task, sendPort),
|
||||
DataTask() => doDataTask(
|
||||
task,
|
||||
requestTimeout ?? const Duration(seconds: 60),
|
||||
sendPort,
|
||||
),
|
||||
_ => throw UnimplementedError(),
|
||||
};
|
||||
}
|
||||
DesktopDownloader.httpClient.close();
|
||||
receivePort.close();
|
||||
sendPort.send('done'); // signals end
|
||||
Isolate.exit();
|
||||
}
|
||||
|
||||
/// Listen async to messages to the isolate, and process these
|
||||
///
|
||||
/// Called as unawaited Future, which completes when the [messagesToIsolate]
|
||||
/// stream is closed
|
||||
Future<void> listenToIncomingMessages(
|
||||
Task task,
|
||||
StreamQueue messagesToIsolate,
|
||||
SendPort sendPort,
|
||||
) async {
|
||||
while (await messagesToIsolate.hasNext) {
|
||||
final message = await messagesToIsolate.next;
|
||||
switch (message) {
|
||||
case 'cancel':
|
||||
isCanceled = true; // checked in loop elsewhere
|
||||
if (task is ParallelDownloadTask) {
|
||||
cancelParallelDownloadTask(task, sendPort);
|
||||
}
|
||||
|
||||
case 'pause':
|
||||
isPaused = true; // checked in loop elsewhere
|
||||
if (task is ParallelDownloadTask) {
|
||||
pauseParallelDownloadTask(task, sendPort);
|
||||
}
|
||||
|
||||
// Status and progress updates are incoming from chunk tasks, part of a
|
||||
// [ParallelDownloadTask]. We update the chunk status/progress and
|
||||
// determine the aggregate status/progress for the parent task. If changed,
|
||||
// we process that update for the parent task as we would for a regular
|
||||
// [DownloadTask].
|
||||
// Note that [task] refers to the parent task, whereas [update.task] refers
|
||||
// to the chunk (child) task
|
||||
case final TaskStatusUpdate update:
|
||||
await chunkStatusUpdate(task, update, sendPort);
|
||||
|
||||
case final TaskProgressUpdate update:
|
||||
chunkProgressUpdate(task, update, sendPort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Transfer all bytes from [inStream] to [outStream], expecting [contentLength]
|
||||
/// total bytes
|
||||
///
|
||||
/// Sends updates via the [sendPort] and can be commanded to cancel/pause via
|
||||
/// the [messagesToIsolate] queue
|
||||
///
|
||||
/// Returns a [TaskStatus] and will throw any exception generated within
|
||||
///
|
||||
/// Note: does not flush or close any streams
|
||||
Future<TaskStatus> transferBytes(
|
||||
Stream<List<int>> inStream,
|
||||
StreamSink<List<int>> outStream,
|
||||
int contentLength,
|
||||
Task task,
|
||||
SendPort sendPort, [
|
||||
Duration requestTimeout = const Duration(seconds: 60),
|
||||
]) async {
|
||||
if (contentLength == 0) {
|
||||
contentLength = -1;
|
||||
}
|
||||
var resultStatus = TaskStatus.complete;
|
||||
try {
|
||||
await outStream.addStream(
|
||||
inStream
|
||||
.timeout(
|
||||
requestTimeout,
|
||||
onTimeout: (sink) {
|
||||
taskException = TaskConnectionException('Connection timed out');
|
||||
resultStatus = TaskStatus.failed;
|
||||
sink.close(); // ends the stream
|
||||
},
|
||||
)
|
||||
.map((bytes) {
|
||||
if (isCanceled) {
|
||||
resultStatus = TaskStatus.canceled;
|
||||
throw StateError('Canceled');
|
||||
}
|
||||
if (isPaused) {
|
||||
resultStatus = TaskStatus.paused;
|
||||
throw StateError('Paused');
|
||||
}
|
||||
bytesTotal += bytes.length;
|
||||
final progress = min(
|
||||
(bytesTotal + startByte).toDouble() / (contentLength + startByte),
|
||||
0.999,
|
||||
);
|
||||
final now = DateTime.now();
|
||||
if (contentLength > 0 && shouldSendProgressUpdate(progress, now)) {
|
||||
processProgressUpdateInIsolate(
|
||||
task,
|
||||
progress,
|
||||
sendPort,
|
||||
contentLength + startByte,
|
||||
);
|
||||
lastProgressUpdate = progress;
|
||||
nextProgressUpdateTime = now.add(
|
||||
const Duration(milliseconds: 500),
|
||||
);
|
||||
}
|
||||
return bytes;
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
if (resultStatus == TaskStatus.complete) {
|
||||
// this was an unintentional error thrown within the stream processing
|
||||
logError(task, e.toString());
|
||||
setTaskError(e);
|
||||
resultStatus = TaskStatus.failed;
|
||||
}
|
||||
}
|
||||
return resultStatus;
|
||||
}
|
||||
|
||||
/// Processes a change in status for the [task]
|
||||
///
|
||||
/// Sends status update via the [sendPort], if requested
|
||||
/// If the task is finished, processes a final progressUpdate update
|
||||
void processStatusUpdateInIsolate(
|
||||
Task task,
|
||||
TaskStatus status,
|
||||
SendPort sendPort,
|
||||
) {
|
||||
final retryNeeded = status == TaskStatus.failed && task.retriesRemaining > 0;
|
||||
// if task is in final state, process a final progressUpdate
|
||||
// A 'failed' progress update is only provided if
|
||||
// a retry is not needed: if it is needed, a `waitingToRetry` progress update
|
||||
// will be generated in the FileDownloader
|
||||
switch (status) {
|
||||
case .complete:
|
||||
processProgressUpdateInIsolate(task, progressComplete, sendPort);
|
||||
|
||||
case .failed when !retryNeeded:
|
||||
processProgressUpdateInIsolate(task, progressFailed, sendPort);
|
||||
|
||||
case .canceled:
|
||||
processProgressUpdateInIsolate(task, progressCanceled, sendPort);
|
||||
|
||||
case .notFound:
|
||||
processProgressUpdateInIsolate(task, progressNotFound, sendPort);
|
||||
|
||||
case .paused:
|
||||
processProgressUpdateInIsolate(task, progressPaused, sendPort);
|
||||
|
||||
default:
|
||||
{}
|
||||
}
|
||||
final statusUpdate = TaskStatusUpdate(
|
||||
task,
|
||||
status,
|
||||
status == TaskStatus.failed ? taskException ?? TaskException('None') : null,
|
||||
status.isFinalState ? responseBody : null,
|
||||
status.isFinalState ? responseHeaders : null,
|
||||
status == TaskStatus.complete || status == TaskStatus.notFound
|
||||
? responseStatusCode
|
||||
: null,
|
||||
status.isFinalState ? mimeType : null,
|
||||
status.isFinalState ? charSet : null,
|
||||
);
|
||||
// Post update if task expects one, or if failed and retry is needed
|
||||
if (task.providesStatusUpdates || retryNeeded) {
|
||||
sendPort.send((
|
||||
'statusUpdate',
|
||||
statusUpdate.task,
|
||||
statusUpdate.status,
|
||||
statusUpdate.exception,
|
||||
statusUpdate.responseBody,
|
||||
statusUpdate.responseHeaders,
|
||||
statusUpdate.responseStatusCode,
|
||||
statusUpdate.mimeType,
|
||||
statusUpdate.charSet,
|
||||
));
|
||||
}
|
||||
if (status.isFinalState) {
|
||||
task.options?.onTaskFinishedCallBack?.call(statusUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
/// Processes a progress update for the [task]
|
||||
///
|
||||
/// Sends progress update via the [sendPort], if requested
|
||||
void processProgressUpdateInIsolate(
|
||||
Task task,
|
||||
double progress,
|
||||
SendPort sendPort, [
|
||||
int expectedFileSize = -1,
|
||||
]) {
|
||||
if (task.providesProgressUpdates) {
|
||||
if (progress > 0 && progress < 1) {
|
||||
// calculate download speed and time remaining
|
||||
final now = DateTime.now();
|
||||
final timeSinceLastUpdate = now.difference(lastProgressUpdateTime);
|
||||
lastProgressUpdateTime = now;
|
||||
if (task is ParallelDownloadTask) {
|
||||
// approximate based on aggregate progress
|
||||
bytesTotal = (progress * expectedFileSize).floor();
|
||||
}
|
||||
final bytesSinceLastUpdate = bytesTotal - bytesTotalAtLastProgressUpdate;
|
||||
bytesTotalAtLastProgressUpdate = bytesTotal;
|
||||
final currentNetworkSpeed = timeSinceLastUpdate.inHours > 0
|
||||
? -1.0
|
||||
: bytesSinceLastUpdate / timeSinceLastUpdate.inMicroseconds;
|
||||
networkSpeed = switch (currentNetworkSpeed) {
|
||||
-1.0 => -1.0,
|
||||
_ when networkSpeed == -1.0 => currentNetworkSpeed,
|
||||
_ => (networkSpeed * 3 + currentNetworkSpeed) / 4.0,
|
||||
};
|
||||
final remainingBytes = (1 - progress) * expectedFileSize;
|
||||
final timeRemaining = networkSpeed == -1.0 || expectedFileSize < 0
|
||||
? const Duration(seconds: -1)
|
||||
: Duration(microseconds: (remainingBytes / networkSpeed).round());
|
||||
sendPort.send((
|
||||
'progressUpdate',
|
||||
task,
|
||||
progress,
|
||||
expectedFileSize,
|
||||
networkSpeed,
|
||||
timeRemaining,
|
||||
));
|
||||
} else {
|
||||
// no download speed or time remaining
|
||||
sendPort.send((
|
||||
'progressUpdate',
|
||||
task,
|
||||
progress,
|
||||
expectedFileSize,
|
||||
-1.0,
|
||||
const Duration(seconds: -1),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a [task] that may be modified through callbacks
|
||||
///
|
||||
/// Callbacks would be attached to the task via its [Task.options] property, and if
|
||||
/// present will be invoked by starting a taskDispatcher on a background isolate, then
|
||||
/// sending the callback request via the MethodChannel
|
||||
Future<Task> getModifiedTask(Task task) async {
|
||||
Task? authTask;
|
||||
final auth = task.options?.auth;
|
||||
if (auth != null) {
|
||||
// refresh token if needed
|
||||
if (auth.isTokenExpired() && auth.onAuthCallback != null) {
|
||||
authTask = await auth.onAuthCallback?.call(task);
|
||||
}
|
||||
authTask ??= task; // either original or newly authorized
|
||||
final newAuth = authTask.options?.auth;
|
||||
if (newAuth == null) {
|
||||
// no auth object on authorized task
|
||||
return authTask;
|
||||
}
|
||||
// insert query parameters and headers
|
||||
final uri = newAuth.addOrUpdateQueryParams(
|
||||
url: authTask.url,
|
||||
queryParams: newAuth.getAccessQueryParams(),
|
||||
);
|
||||
final headers = {...authTask.headers, ...newAuth.getAccessHeaders()};
|
||||
authTask = authTask.copyWith(url: uri.toString(), headers: headers);
|
||||
}
|
||||
authTask = authTask ??= task;
|
||||
final modifiedTask = await authTask.options?.onTaskStartCallBack?.call(
|
||||
authTask,
|
||||
);
|
||||
return modifiedTask ?? authTask;
|
||||
}
|
||||
|
||||
// The following functions are related to multipart uploads and are
|
||||
// by and large copied from the dart:http package. Similar implementations
|
||||
// in Kotlin and Swift are translations of the same code
|
||||
|
||||
/// Returns the multipart entry for one field name/value pair
|
||||
String fieldEntry(String name, String value) =>
|
||||
'--$boundary$lineFeed${headerForField(name, value)}$value$lineFeed';
|
||||
|
||||
/// Returns the header string for a field.
|
||||
///
|
||||
/// The return value is guaranteed to contain only ASCII characters.
|
||||
String headerForField(String name, String value) {
|
||||
var header = 'content-disposition: form-data; name="${browserEncode(name)}"';
|
||||
if (isJsonString(value)) {
|
||||
header =
|
||||
'$header\r\n'
|
||||
'content-type: application/json; charset=utf-8';
|
||||
} else if (!isPlainAscii(value)) {
|
||||
header =
|
||||
'$header\r\n'
|
||||
'content-type: text/plain; charset=utf-8\r\n'
|
||||
'content-transfer-encoding: binary';
|
||||
}
|
||||
return '$header\r\n\r\n';
|
||||
}
|
||||
|
||||
/// A regular expression that matches strings that are composed entirely of
|
||||
/// ASCII-compatible characters.
|
||||
final _asciiOnly = RegExp(r'^[\x00-\x7F]+$');
|
||||
|
||||
/// A regular expression that matches strings that start and end with JSON object
|
||||
/// or JSON array characters, and that therefore are presumed to be in JSON
|
||||
/// format
|
||||
final _jsonString = RegExp(r'^\s*(\{.*\}|\[.*\])\s*$');
|
||||
|
||||
final _newlineRegExp = RegExp(r'\r\n|\r|\n');
|
||||
|
||||
/// Returns whether [string] is composed entirely of ASCII-compatible
|
||||
/// characters.
|
||||
bool isPlainAscii(String string) => _asciiOnly.hasMatch(string);
|
||||
|
||||
/// Returns whether [string] is a JSON formatted string, based on simple
|
||||
/// test of start and end characters {} or []
|
||||
bool isJsonString(String string) => _jsonString.hasMatch(string);
|
||||
|
||||
/// Encode [value] in the same way browsers do.
|
||||
String browserEncode(String value) =>
|
||||
// http://tools.ietf.org/html/rfc2388 mandates some complex encodings for
|
||||
// field names and file names, but in practice user agents seem not to
|
||||
// follow this at all. Instead, they URL-encode `\r`, `\n`, and `\r\n` as
|
||||
// `\r\n`; URL-encode `"`; and do nothing else (even for `%` or non-ASCII
|
||||
// characters). We follow their behavior.
|
||||
value.replaceAll(_newlineRegExp, '%0D%0A').replaceAll('"', '%22');
|
||||
|
||||
/// Returns the length of the [string] in bytes when utf-8 encoded
|
||||
int lengthInBytes(String string) => utf8.encode(string).length;
|
||||
|
||||
/// Log an error for this task
|
||||
void logError(Task task, String error) {
|
||||
log.fine('Error for taskId ${task.taskId}: $error');
|
||||
}
|
||||
|
||||
/// Set the [taskException] variable based on error e
|
||||
void setTaskError(dynamic e) {
|
||||
taskException = switch (e) {
|
||||
HttpException() ||
|
||||
TimeoutException() => TaskConnectionException(e.toString()),
|
||||
IOException() => TaskFileSystemException(e.toString()),
|
||||
TaskException() => e,
|
||||
_ => TaskException(e.toString()),
|
||||
};
|
||||
}
|
||||
|
||||
/// Return the response's content as a String, or null if unable
|
||||
Future<String?> responseContent(http.StreamedResponse response) {
|
||||
try {
|
||||
return response.stream.bytesToString();
|
||||
} catch (e) {
|
||||
log.fine(
|
||||
'Could not read response content from httpResponseCode ${response.statusCode}: $e',
|
||||
);
|
||||
return Future.value(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if [currentProgress] > [lastProgressUpdate] + 2% and
|
||||
/// [now] > [nextProgressUpdateTime], or if there was progress and
|
||||
/// [now] > [nextProgressUpdateTime] + 2 seconds
|
||||
bool shouldSendProgressUpdate(double currentProgress, DateTime now) =>
|
||||
(currentProgress - lastProgressUpdate > 0.02 &&
|
||||
now.isAfter(nextProgressUpdateTime)) ||
|
||||
(currentProgress > lastProgressUpdate &&
|
||||
now.isAfter(nextProgressUpdateTime.add(const Duration(seconds: 2))));
|
||||
+454
@@ -0,0 +1,454 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import '../chunk.dart';
|
||||
import '../exceptions.dart';
|
||||
import '../models.dart';
|
||||
import '../task.dart';
|
||||
import '../utils.dart';
|
||||
import 'desktop_downloader.dart';
|
||||
import 'download_isolate.dart';
|
||||
import 'isolate.dart';
|
||||
|
||||
/// A [ParallelDownloadTask] pings the server to get the content-length of the
|
||||
/// download, then creates a list of [Chunk]s, each representing a portion
|
||||
/// of the download. Each chunk-task has its group set to 'chunk' and
|
||||
/// has the taskId of the parent [ParallelDownloadTask] in its
|
||||
/// [Task.metaData] field.
|
||||
/// The isolate sends 'enqueue' messages back to the [DesktopDownloader] to
|
||||
/// start each chunk-task, just like any other download task.
|
||||
/// Messages with group 'chunk' are intercepted in the [DesktopDownloader],
|
||||
/// where the sendPort for the isolate running the parent task is
|
||||
/// looked up, and the update is sent to the isolate via that sendPort.
|
||||
/// In the isolate, the update is processed and the new status/progress
|
||||
/// of the [ParallelDownloadTask] is determined. If the status/progress has
|
||||
/// changed, an update is sent and the status is processed (e.g., a complete
|
||||
/// status triggers the piecing together of the downloaded file from
|
||||
/// its chunk pieces).
|
||||
///
|
||||
/// Similarly, pause and cancel commands are sent to all chunk tasks before
|
||||
/// updating the status of the parent [ParallelDownloadTask]
|
||||
|
||||
late ParallelDownloadTask parentTask;
|
||||
var chunks = <Chunk>[]; // chunks associated with this download
|
||||
var lastTaskStatus = TaskStatus.running;
|
||||
Completer<TaskStatusUpdate> parallelTaskStatusUpdateCompleter = Completer();
|
||||
var parallelDownloadContentLength = -1;
|
||||
|
||||
/// Execute the parallel download task
|
||||
///
|
||||
/// Sends updates via the [sendPort] and can be commanded to cancel/pause via
|
||||
/// the [messagesToIsolate] queue.
|
||||
///
|
||||
/// If [isResume] is false, we create [Chunk]s and enqueue each of its tasks
|
||||
/// by sending a message to the [DesktopDownloader], then wait for the
|
||||
/// completion of [parallelTaskStatusUpdateCompleter]
|
||||
///
|
||||
/// If [isResume] is true, [resumeData] contains the json encoded [chunks] list,
|
||||
/// and the associated chunk [DownloadTask] tasks will be started by the
|
||||
/// [DesktopDownloader], so we just wait for completion of
|
||||
/// [parallelTaskStatusUpdateCompleter]
|
||||
///
|
||||
/// Incoming messages from the [DesktopDownloader] are received in the
|
||||
/// [listenToIncomingMessages] function
|
||||
Future<void> doParallelDownloadTask(
|
||||
ParallelDownloadTask task,
|
||||
ResumeData? resumeData,
|
||||
bool isResume,
|
||||
Duration requestTimeout,
|
||||
SendPort sendPort,
|
||||
) async {
|
||||
parentTask = task;
|
||||
if (!isResume) {
|
||||
// start the download by creating [Chunk]s and enqueuing chunk tasks
|
||||
final response = await DesktopDownloader.httpClientForUrl(task.url)
|
||||
.head(Uri.parse(task.url), headers: task.headers)
|
||||
.timeout(requestTimeout);
|
||||
responseHeaders = response.headers;
|
||||
responseStatusCode = response.statusCode;
|
||||
if ([200, 201, 202, 203, 204, 205, 206].contains(response.statusCode)) {
|
||||
// get suggested filename if needed, and change task and parentTask
|
||||
if (!task.hasFilename) {
|
||||
task = (await taskWithSuggestedFilename(
|
||||
task,
|
||||
response.headers,
|
||||
true,
|
||||
)) as ParallelDownloadTask;
|
||||
parentTask = task;
|
||||
log.finest(
|
||||
'Suggested filename for taskId ${task.taskId}: ${task.filename}',
|
||||
);
|
||||
}
|
||||
extractContentType(response.headers);
|
||||
chunks = createChunks(task, response.headers);
|
||||
for (final chunk in chunks) {
|
||||
// Ask main isolate to enqueue the child task. Updates related to the child
|
||||
// will be sent to this isolate (the child's metaData contains the parent taskId).
|
||||
sendPort.send(('enqueueChild', chunk.task));
|
||||
}
|
||||
// wait for all chunk tasks to complete
|
||||
final statusUpdate = await parallelTaskStatusUpdateCompleter.future;
|
||||
processStatusUpdateInIsolate(task, statusUpdate.status, sendPort);
|
||||
} else {
|
||||
log.fine(
|
||||
'TaskId ${task.taskId}: Invalid server response code ${response.statusCode}',
|
||||
);
|
||||
// not an OK response
|
||||
responseBody = response.body;
|
||||
if (response.statusCode == 404) {
|
||||
processStatusUpdateInIsolate(task, TaskStatus.notFound, sendPort);
|
||||
} else {
|
||||
taskException = TaskHttpException(
|
||||
responseBody?.isNotEmpty == true
|
||||
? responseBody!
|
||||
: response.reasonPhrase ?? 'Invalid HTTP Request',
|
||||
response.statusCode,
|
||||
);
|
||||
processStatusUpdateInIsolate(task, TaskStatus.failed, sendPort);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// resume: reconstruct [chunks] and wait for all chunk tasks to complete
|
||||
chunks = List.from(
|
||||
jsonDecode(resumeData!.data, reviver: Chunk.listReviver),
|
||||
);
|
||||
parallelDownloadContentLength = chunks.fold(
|
||||
0,
|
||||
(previousValue, chunk) =>
|
||||
previousValue + chunk.toByte - chunk.fromByte + 1,
|
||||
);
|
||||
final statusUpdate = await parallelTaskStatusUpdateCompleter.future;
|
||||
processStatusUpdateInIsolate(task, statusUpdate.status, sendPort);
|
||||
}
|
||||
}
|
||||
|
||||
/// Process incoming [update] for a chunk, within the [ParallelDownloadTask]
|
||||
/// represented by [task]
|
||||
Future<void> chunkStatusUpdate(
|
||||
Task task,
|
||||
TaskStatusUpdate update,
|
||||
SendPort sendPort,
|
||||
) async {
|
||||
final chunkTask = update.task;
|
||||
// first check for fail -> retry
|
||||
if (update.status == TaskStatus.failed && chunkTask.retriesRemaining > 0) {
|
||||
chunkTask.decreaseRetriesRemaining();
|
||||
final waitTime = Duration(
|
||||
seconds: 2 << min(chunkTask.retries - chunkTask.retriesRemaining - 1, 8),
|
||||
);
|
||||
log.finer(
|
||||
'Chunk task with taskId ${chunkTask.taskId} failed, waiting ${waitTime.inSeconds}'
|
||||
' seconds before retrying. ${chunkTask.retriesRemaining}'
|
||||
' retries remaining',
|
||||
);
|
||||
Future.delayed(waitTime, () async {
|
||||
// after delay, resume or enqueue task again if it's still waiting
|
||||
sendPort.send(('enqueueChild', chunkTask));
|
||||
});
|
||||
} else {
|
||||
// no retry
|
||||
final newStatusUpdate = updateChunkStatus(update);
|
||||
switch (newStatusUpdate) {
|
||||
case TaskStatus.complete:
|
||||
final result = await stitchChunks();
|
||||
parallelTaskStatusUpdateCompleter.complete(
|
||||
TaskStatusUpdate(
|
||||
task,
|
||||
result,
|
||||
null,
|
||||
responseBody,
|
||||
responseHeaders,
|
||||
responseStatusCode,
|
||||
),
|
||||
);
|
||||
|
||||
case TaskStatus.failed:
|
||||
taskException = update.exception;
|
||||
responseBody = update.responseBody;
|
||||
cancelAllChunkTasks(sendPort);
|
||||
parallelTaskStatusUpdateCompleter.complete(
|
||||
TaskStatusUpdate(
|
||||
task,
|
||||
TaskStatus.failed,
|
||||
taskException,
|
||||
responseBody,
|
||||
responseHeaders,
|
||||
responseStatusCode,
|
||||
),
|
||||
);
|
||||
|
||||
case TaskStatus.notFound:
|
||||
responseBody = update.responseBody;
|
||||
cancelAllChunkTasks(sendPort);
|
||||
parallelTaskStatusUpdateCompleter.complete(
|
||||
TaskStatusUpdate(
|
||||
task,
|
||||
TaskStatus.notFound,
|
||||
null,
|
||||
responseBody,
|
||||
responseHeaders,
|
||||
responseStatusCode,
|
||||
),
|
||||
);
|
||||
|
||||
default:
|
||||
// ignore all other status updates, including null
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the status for this chunk, and return the status for the parent task
|
||||
/// as derived from the sum of the child tasks, or null if undefined
|
||||
///
|
||||
/// The updates are received from the [DeskTopDownloader], which intercepts
|
||||
/// status updates for the [chunkGroup]. Not all regular statuses are passed on
|
||||
TaskStatus? updateChunkStatus(TaskStatusUpdate update) {
|
||||
final chunk = chunks.firstWhereOrNull((chunk) => chunk.task == update.task);
|
||||
if (chunk == null) {
|
||||
return null; // chunk is not part of this parent task
|
||||
}
|
||||
chunk.status = update.status;
|
||||
final newStatusUpdate = parentTaskStatus();
|
||||
if ((newStatusUpdate != null && newStatusUpdate != lastTaskStatus)) {
|
||||
lastTaskStatus = newStatusUpdate;
|
||||
return newStatusUpdate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Returns the [TaskStatus] for the parent of this chunk, as derived from
|
||||
/// the 'sum' of the child tasks, or null if undetermined
|
||||
///
|
||||
/// The updates are received from the [DeskTopDownloader], which intercepts
|
||||
/// status updates for the [chunkGroup]
|
||||
TaskStatus? parentTaskStatus() {
|
||||
final failed = chunks.firstWhereOrNull(
|
||||
(chunk) => chunk.status == TaskStatus.failed,
|
||||
);
|
||||
if (failed != null) {
|
||||
return TaskStatus.failed;
|
||||
}
|
||||
final notFound = chunks.firstWhereOrNull(
|
||||
(chunk) => chunk.status == TaskStatus.notFound,
|
||||
);
|
||||
if (notFound != null) {
|
||||
return TaskStatus.notFound;
|
||||
}
|
||||
final allComplete = chunks.every(
|
||||
(chunk) => chunk.status == TaskStatus.complete,
|
||||
);
|
||||
if (allComplete) {
|
||||
return TaskStatus.complete;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Process incoming [update] for a chunk, within the [ParallelDownloadTask]
|
||||
/// represented by [task]
|
||||
void chunkProgressUpdate(
|
||||
Task task,
|
||||
TaskProgressUpdate update,
|
||||
SendPort sendPort,
|
||||
) {
|
||||
// update of child task of a [ParallelDownloadTask], only for regular
|
||||
// progress updates
|
||||
final now = DateTime.now();
|
||||
if (update.progress > 0 && update.progress < 1) {
|
||||
final parentProgressUpdate = updateChunkProgress(update);
|
||||
if (parentProgressUpdate != null &&
|
||||
shouldSendProgressUpdate(parentProgressUpdate, now)) {
|
||||
processProgressUpdateInIsolate(
|
||||
task,
|
||||
parentProgressUpdate,
|
||||
sendPort,
|
||||
parallelDownloadContentLength,
|
||||
);
|
||||
lastProgressUpdate = parentProgressUpdate;
|
||||
nextProgressUpdateTime = now.add(const Duration(milliseconds: 500));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the progress for this chunk, and return the progress for the parent
|
||||
/// task as derived from the sum of the child tasks, or null if undefined
|
||||
///
|
||||
/// The updates are received from the [DeskTopDownloader], which intercepts
|
||||
/// progress updates for the [chunkGroup].
|
||||
/// Only true progress updates (in range 0-1) are passed on to this method
|
||||
double? updateChunkProgress(TaskProgressUpdate update) {
|
||||
final chunk = chunks.firstWhereOrNull((chunk) => chunk.task == update.task);
|
||||
if (chunk == null) {
|
||||
return null; // chunk is not part of this parent task
|
||||
}
|
||||
chunk.progress = update.progress;
|
||||
return parentTaskProgress();
|
||||
}
|
||||
|
||||
/// Returns the progress for the parent of this chunk, as derived
|
||||
/// from the 'sum' of the child tasks
|
||||
///
|
||||
/// The updates are received from the [DeskTopDownloader], which intercepts
|
||||
/// progress updates for the [chunkGroup].
|
||||
/// Only true progress updates (in range 0-1) are passed on to this method,
|
||||
/// so we just calculate the average progress
|
||||
double parentTaskProgress() {
|
||||
final avgProgress =
|
||||
chunks.fold(
|
||||
0.0,
|
||||
(previousValue, chunk) => previousValue + chunk.progress,
|
||||
) /
|
||||
chunks.length;
|
||||
return avgProgress;
|
||||
}
|
||||
|
||||
/// Cancel this [ParallelDownloadTask]
|
||||
void cancelParallelDownloadTask(ParallelDownloadTask task, SendPort sendPort) {
|
||||
cancelAllChunkTasks(sendPort);
|
||||
parallelTaskStatusUpdateCompleter.complete(
|
||||
TaskStatusUpdate(task, TaskStatus.canceled),
|
||||
);
|
||||
}
|
||||
|
||||
/// Cancel the tasks associated with each chunk
|
||||
///
|
||||
/// Accomplished by sending list of taskIds to cancel to the
|
||||
/// [DesktopDownloader]
|
||||
void cancelAllChunkTasks(SendPort sendPort) {
|
||||
sendPort.send((
|
||||
'cancelTasksWithId',
|
||||
chunks.map((e) => e.task.taskId).toList(),
|
||||
));
|
||||
}
|
||||
|
||||
/// Pause this [ParallelDownloadTask]
|
||||
///
|
||||
/// Because each [Chunk] is a [DownloadTask], each is paused
|
||||
/// and generates its own [ResumeData].
|
||||
/// [ResumeData] for a [ParallelDownloadTask] is the list of [Chunk]s,
|
||||
/// including their status and progress, json encoded.
|
||||
/// To resume, we need to recreate the list of [Chunk]s and pass this with
|
||||
/// the resumed [ParallelDownloadTask], then resume each of the
|
||||
/// [DownloadTask]s. This is done in [DesktopDownloader.resume]
|
||||
void pauseParallelDownloadTask(ParallelDownloadTask task, SendPort sendPort) {
|
||||
pauseAllChunkTasks(sendPort);
|
||||
sendPort.send(('resumeData', jsonEncode(chunks), -1, null));
|
||||
parallelTaskStatusUpdateCompleter.complete(
|
||||
TaskStatusUpdate(task, TaskStatus.paused),
|
||||
);
|
||||
}
|
||||
|
||||
/// Pause the tasks associated with each chunk
|
||||
///
|
||||
/// Accomplished by sending list of tasks to pause to the
|
||||
/// [DesktopDownloader]
|
||||
void pauseAllChunkTasks(SendPort sendPort) {
|
||||
sendPort.send(('pauseTasks', chunks.map((e) => e.task).toList()));
|
||||
}
|
||||
|
||||
/// Stitch all chunks together into one file, per the [parentTask]
|
||||
Future<TaskStatus> stitchChunks() async {
|
||||
IOSink? outStream;
|
||||
StreamSubscription? subscription;
|
||||
try {
|
||||
final outFile = File(await parentTask.filePath());
|
||||
if (await outFile.exists()) {
|
||||
await outFile.delete();
|
||||
}
|
||||
outStream = outFile.openWrite();
|
||||
for (final chunk in chunks.sorted((a, b) => a.fromByte - b.fromByte)) {
|
||||
final inFile = File(await chunk.task.filePath());
|
||||
if (!await inFile.exists()) {
|
||||
throw const FileSystemException('Missing chunk file');
|
||||
}
|
||||
final inStream = inFile.openRead();
|
||||
final doneCompleter = Completer<bool>();
|
||||
subscription = inStream.listen(
|
||||
(bytes) {
|
||||
outStream?.add(bytes);
|
||||
},
|
||||
onDone: () => doneCompleter.complete(true),
|
||||
onError: (error) {
|
||||
logError(parentTask, e.toString());
|
||||
setTaskError(error);
|
||||
doneCompleter.complete(false);
|
||||
},
|
||||
);
|
||||
final success = await doneCompleter.future;
|
||||
if (!success) {
|
||||
return TaskStatus.failed;
|
||||
}
|
||||
subscription.cancel();
|
||||
await inFile.delete();
|
||||
}
|
||||
await outStream.flush();
|
||||
} catch (e) {
|
||||
logError(parentTask, e.toString());
|
||||
setTaskError(e);
|
||||
return TaskStatus.failed;
|
||||
} finally {
|
||||
await outStream?.close();
|
||||
subscription?.cancel();
|
||||
for (final chunk in chunks) {
|
||||
try {
|
||||
final file = File(await chunk.task.filePath());
|
||||
await file.delete();
|
||||
} on FileSystemException {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
return TaskStatus.complete;
|
||||
}
|
||||
|
||||
/// Returns a list of chunk information for this task, and sets
|
||||
/// [parallelDownloadContentLength] to the total length of the download
|
||||
///
|
||||
/// Throws a StateError if any information is missing, which should lead
|
||||
/// to a failure of the [ParallelDownloadTask]
|
||||
List<Chunk> createChunks(
|
||||
ParallelDownloadTask task,
|
||||
Map<String, String> headers,
|
||||
) {
|
||||
try {
|
||||
final numChunks = task.urls.length * task.chunks;
|
||||
final contentLength = getContentLength(headers, task);
|
||||
if (contentLength <= 0) {
|
||||
throw StateError(
|
||||
'Server does not provide content length - cannot chunk download. '
|
||||
'If you know the length, set Range or Known-Content-Length header',
|
||||
);
|
||||
}
|
||||
parallelDownloadContentLength = contentLength;
|
||||
try {
|
||||
headers.entries.firstWhere(
|
||||
(element) =>
|
||||
element.key.toLowerCase() == 'accept-ranges' &&
|
||||
element.value == 'bytes',
|
||||
);
|
||||
} on StateError {
|
||||
throw StateError('Server does not accept ranges - cannot chunk download');
|
||||
}
|
||||
final chunkSize = (contentLength / numChunks).ceil();
|
||||
return [
|
||||
for (var i = 0; i < numChunks; i++)
|
||||
Chunk(
|
||||
parentTask: task,
|
||||
url: task.urls[i % task.urls.length],
|
||||
filename: Random().nextInt(1 << 32).toString(),
|
||||
fromByte: i * chunkSize,
|
||||
toByte: min(i * chunkSize + chunkSize - 1, contentLength - 1),
|
||||
),
|
||||
];
|
||||
} on StateError {
|
||||
throw StateError(
|
||||
'Server does not provide content length - cannot chunk download. '
|
||||
'If you know the length, set Range or Known-Content-Length header',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:mime/mime.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../exceptions.dart';
|
||||
import '../models.dart';
|
||||
import '../task.dart';
|
||||
import 'desktop_downloader.dart';
|
||||
import 'isolate.dart';
|
||||
|
||||
const boundary = '-----background_downloader-akjhfw281onqciyhnIk';
|
||||
const lineFeed = '\r\n';
|
||||
|
||||
/// Do the binary or multi-part upload task
|
||||
///
|
||||
/// Sends updates via the [sendPort] and can be commanded to cancel via
|
||||
/// the [messagesToIsolate] queue
|
||||
Future<void> doUploadTask(UploadTask task, SendPort sendPort) async {
|
||||
final (updatedTask, resultStatus) = task.post == 'binary'
|
||||
? await binaryUpload(task, sendPort)
|
||||
: await multipartUpload(task, sendPort);
|
||||
processStatusUpdateInIsolate(updatedTask, resultStatus, sendPort);
|
||||
}
|
||||
|
||||
/// Do the binary upload and return the TaskStatus
|
||||
///
|
||||
/// Content-Disposition header will be:
|
||||
/// - set to 'attachment = "filename"' if the task.headers field does not contain
|
||||
/// an entry for 'Content-Disposition'
|
||||
/// - not set at all (i.e. omitted) if the task.headers field contains an entry
|
||||
/// for 'Content-Disposition' with the value '' (an empty string)
|
||||
/// - set to the value of task.headers['Content-Disposition'] in all other cases
|
||||
///
|
||||
/// The mime-type will be set to [Task.mimeType]
|
||||
///
|
||||
/// Sends updates via the [sendPort] and can be commanded to cancel via
|
||||
/// the [messagesToIsolate] queue
|
||||
Future<(Task, TaskStatus)> binaryUpload(
|
||||
UploadTask task,
|
||||
SendPort sendPort,
|
||||
) async {
|
||||
final filePath = await task.filePath();
|
||||
final inFile = File(filePath);
|
||||
if (!inFile.existsSync()) {
|
||||
final message = 'File to upload does not exist: $filePath';
|
||||
logError(task, message);
|
||||
taskException = TaskFileSystemException(message);
|
||||
return (task, TaskStatus.failed);
|
||||
}
|
||||
final fileSize = inFile.lengthSync();
|
||||
if (fileSize == 0) {
|
||||
final message = 'File $filePath has 0 length';
|
||||
logError(task, message);
|
||||
taskException = TaskFileSystemException(message);
|
||||
return (task, TaskStatus.failed);
|
||||
}
|
||||
var resultStatus = TaskStatus.failed;
|
||||
try {
|
||||
// Extract Range header information, if present, for partial upload
|
||||
int start = 0;
|
||||
int end = fileSize - 1; // Default to the whole file
|
||||
if (task.headers.containsKey('Range')) {
|
||||
final rangeHeader = task.headers['Range']!;
|
||||
final match = RegExp(r'bytes=(\d+)-(\d*)').firstMatch(rangeHeader);
|
||||
if (match != null) {
|
||||
start = int.parse(match.group(1)!);
|
||||
if (match.group(2)!.isNotEmpty) {
|
||||
end = int.parse(match.group(2)!);
|
||||
}
|
||||
} else {
|
||||
final message = 'Invalid Range header $rangeHeader';
|
||||
logError(task, message);
|
||||
taskException = TaskException(message);
|
||||
return (task, TaskStatus.failed);
|
||||
}
|
||||
task.headers.remove('Range'); // not passed on to server
|
||||
}
|
||||
if (task case UriUploadTask(fileUri: final fileUri)
|
||||
when fileUri != null && task.filename.isEmpty) {
|
||||
// for UriTasks without a filename, derive it from the Uri
|
||||
task = task.copyWith(filename: fileUri.pathSegments.last);
|
||||
}
|
||||
final contentLength = end - start + 1;
|
||||
final client = DesktopDownloader.httpClientForUrl(task.url);
|
||||
final request = http.StreamedRequest(
|
||||
task.httpRequestMethod,
|
||||
Uri.parse(task.url),
|
||||
);
|
||||
request.maxRedirects = defaultMaxRedirects;
|
||||
request.headers.addAll(task.headers);
|
||||
request.contentLength = contentLength;
|
||||
request.headers['Content-Type'] = task.mimeType;
|
||||
final taskContentDisposition =
|
||||
task.headers['Content-Disposition'] ??
|
||||
task.headers['content-disposition'];
|
||||
if (taskContentDisposition != '') {
|
||||
request.headers['Content-Disposition'] =
|
||||
taskContentDisposition ??
|
||||
'attachment; filename="${Uri.encodeComponent(task.filename)}"';
|
||||
} else {
|
||||
request.headers.remove('Content-Disposition');
|
||||
}
|
||||
request.persistentConnection = false;
|
||||
// initiate the request and handle completion async
|
||||
final requestCompleter = Completer();
|
||||
var transferBytesResult = TaskStatus.failed;
|
||||
client.send(request).then((response) async {
|
||||
// request completed, so send status update and finish
|
||||
resultStatus =
|
||||
transferBytesResult == TaskStatus.complete &&
|
||||
!okResponses.contains(response.statusCode)
|
||||
? TaskStatus.failed
|
||||
: transferBytesResult;
|
||||
responseBody = await responseContent(response);
|
||||
responseHeaders = response.headers;
|
||||
responseStatusCode = response.statusCode;
|
||||
taskException ??= TaskHttpException(
|
||||
responseBody?.isNotEmpty == true
|
||||
? responseBody!
|
||||
: response.reasonPhrase ?? 'Invalid HTTP response',
|
||||
response.statusCode,
|
||||
);
|
||||
if (response.statusCode == 404) {
|
||||
resultStatus = TaskStatus.notFound;
|
||||
}
|
||||
requestCompleter.complete();
|
||||
});
|
||||
// send the bytes to the request sink
|
||||
final inStream = inFile.openRead(start, end + 1);
|
||||
transferBytesResult = await transferBytes(
|
||||
inStream,
|
||||
request.sink,
|
||||
fileSize,
|
||||
task,
|
||||
sendPort,
|
||||
);
|
||||
request.sink.close(); // triggers request completion, handled above
|
||||
if (isCanceled) {
|
||||
// cancellation overrides other results
|
||||
resultStatus = TaskStatus.canceled;
|
||||
} else {
|
||||
await requestCompleter.future; // wait for request to complete
|
||||
}
|
||||
} catch (e) {
|
||||
resultStatus = TaskStatus.failed;
|
||||
setTaskError(e);
|
||||
}
|
||||
return (task, resultStatus);
|
||||
}
|
||||
|
||||
/// Do the multipart upload and return the TaskStatus
|
||||
///
|
||||
/// Sends updates via the [sendPort] and can be commanded to cancel via
|
||||
/// the [messagesToIsolate] queue
|
||||
Future<(Task, TaskStatus)> multipartUpload(
|
||||
UploadTask task,
|
||||
SendPort sendPort,
|
||||
) async {
|
||||
// field portion of the multipart, all in one string
|
||||
// multiple values should be encoded as '"value1", "value2", ...'
|
||||
final multiValueRegEx = RegExp(r'^(?:"[^"]+"\s*,\s*)+"[^"]+"$');
|
||||
var fieldsString = '';
|
||||
for (final entry in task.fields.entries) {
|
||||
if (multiValueRegEx.hasMatch(entry.value)) {
|
||||
// extract multiple values from entry.value
|
||||
for (final match in RegExp(r'"([^"]+)"').allMatches(entry.value)) {
|
||||
fieldsString += fieldEntry(entry.key, match.group(1) ?? 'error');
|
||||
}
|
||||
} else {
|
||||
fieldsString += fieldEntry(
|
||||
entry.key,
|
||||
entry.value,
|
||||
); // single value for key
|
||||
}
|
||||
}
|
||||
// File portion of the multi-part
|
||||
// Assumes list of files. If only one file, that becomes a list of length one.
|
||||
// For each file, determine contentDispositionString, contentTypeString
|
||||
// and file length, so that we can calculate total size of upload
|
||||
const separator = '$lineFeed--$boundary$lineFeed'; // between files
|
||||
const terminator = '$lineFeed--$boundary--$lineFeed'; // after last file
|
||||
final filesData = (task is MultiUploadTask)
|
||||
? await task
|
||||
.extractFilesData() // MultiUpload case
|
||||
: [
|
||||
(task.fileField, await task.filePath(), task.mimeType),
|
||||
]; // one file Upload case
|
||||
final contentDispositionStrings = <String>[];
|
||||
final contentTypeStrings = <String>[];
|
||||
final fileLengths = <int>[];
|
||||
for (final (fileField, path, mimeType) in filesData) {
|
||||
final file = File(path);
|
||||
if (!await file.exists()) {
|
||||
logError(task, 'File to upload does not exist: $path');
|
||||
taskException = TaskFileSystemException(
|
||||
'File to upload does not exist: $path',
|
||||
);
|
||||
return (task, TaskStatus.failed);
|
||||
}
|
||||
final resolvedMimeType = mimeType.isEmpty ? lookupMimeType(path) : mimeType;
|
||||
final derivedFilename = p.basename(file.path);
|
||||
if (filesData.length == 1) {
|
||||
// only for single file uploads do we set the task's filename property
|
||||
if (task case UriUploadTask(fileUri: final fileUri)
|
||||
when fileUri != null) {
|
||||
task = task.copyWith(filename: fileUri.pathSegments.last);
|
||||
} else {
|
||||
task = task.copyWith(filename: derivedFilename);
|
||||
}
|
||||
}
|
||||
contentDispositionStrings.add(
|
||||
'Content-Disposition: form-data; name="${browserEncode(fileField)}"; '
|
||||
'filename="${browserEncode(derivedFilename)}"$lineFeed',
|
||||
);
|
||||
contentTypeStrings.add('Content-Type: $resolvedMimeType$lineFeed$lineFeed');
|
||||
fileLengths.add(file.lengthSync());
|
||||
}
|
||||
final fileDataLength =
|
||||
contentDispositionStrings.fold<int>(
|
||||
0,
|
||||
(sum, string) => sum + lengthInBytes(string),
|
||||
) +
|
||||
contentTypeStrings.fold<int>(0, (sum, string) => sum + string.length) +
|
||||
fileLengths.fold<int>(0, (sum, length) => sum + length) +
|
||||
separator.length * contentDispositionStrings.length +
|
||||
2;
|
||||
final contentLength =
|
||||
lengthInBytes(fieldsString) +
|
||||
'--$boundary$lineFeed'.length +
|
||||
fileDataLength;
|
||||
var resultStatus = TaskStatus.failed;
|
||||
try {
|
||||
// setup the connection
|
||||
final client = DesktopDownloader.httpClientForUrl(task.url);
|
||||
final request = http.StreamedRequest(
|
||||
task.httpRequestMethod,
|
||||
Uri.parse(task.url),
|
||||
);
|
||||
request.maxRedirects = defaultMaxRedirects;
|
||||
request.contentLength = contentLength;
|
||||
request.headers.addAll(task.headers);
|
||||
request.headers.addAll({
|
||||
'Content-Type': 'multipart/form-data; boundary=$boundary',
|
||||
'Accept-Charset': 'UTF-8',
|
||||
'Connection': 'Keep-Alive',
|
||||
'Cache-Control': 'no-cache',
|
||||
});
|
||||
request.persistentConnection = false;
|
||||
// initiate the request and handle completion async
|
||||
final requestCompleter = Completer();
|
||||
var transferBytesResult = TaskStatus.failed;
|
||||
client.send(request).then((response) async {
|
||||
// request completed, so send status update and finish
|
||||
resultStatus =
|
||||
transferBytesResult == TaskStatus.complete &&
|
||||
!okResponses.contains(response.statusCode)
|
||||
? TaskStatus.failed
|
||||
: transferBytesResult;
|
||||
responseBody = await responseContent(response);
|
||||
responseHeaders = response.headers;
|
||||
responseStatusCode = response.statusCode;
|
||||
taskException ??= TaskHttpException(
|
||||
responseBody?.isNotEmpty == true
|
||||
? responseBody!
|
||||
: response.reasonPhrase ?? 'Invalid HTTP response',
|
||||
response.statusCode,
|
||||
);
|
||||
if (response.statusCode == 404) {
|
||||
resultStatus = TaskStatus.notFound;
|
||||
}
|
||||
requestCompleter.complete();
|
||||
});
|
||||
|
||||
// write fields
|
||||
request.sink.add(utf8.encode('$fieldsString--$boundary$lineFeed'));
|
||||
// write each file
|
||||
for (final (index, fileData) in filesData.indexed) {
|
||||
request.sink.add(utf8.encode(contentDispositionStrings[index]));
|
||||
request.sink.add(utf8.encode(contentTypeStrings[index]));
|
||||
// send the bytes to the request sink
|
||||
final inStream = File(fileData.$2).openRead();
|
||||
transferBytesResult = await transferBytes(
|
||||
inStream,
|
||||
request.sink,
|
||||
contentLength,
|
||||
task,
|
||||
sendPort,
|
||||
);
|
||||
if (transferBytesResult != TaskStatus.complete || isCanceled) {
|
||||
break;
|
||||
} else {
|
||||
request.sink.add(
|
||||
utf8.encode(fileData == filesData.last ? terminator : separator),
|
||||
);
|
||||
}
|
||||
}
|
||||
request.sink.close(); // triggers request completion, handled above
|
||||
await requestCompleter.future; // wait for request to complete
|
||||
} catch (e) {
|
||||
resultStatus = TaskStatus.failed;
|
||||
setTaskError(e);
|
||||
}
|
||||
if (isCanceled) {
|
||||
// cancellation overrides other results
|
||||
resultStatus = TaskStatus.canceled;
|
||||
}
|
||||
return (task, resultStatus);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'dart:convert';
|
||||
|
||||
const _exceptions = {
|
||||
'TaskException': TaskException.new,
|
||||
'TaskFileSystemException': TaskFileSystemException.new,
|
||||
'TaskUrlException': TaskUrlException.new,
|
||||
'TaskConnectionException': TaskConnectionException.new,
|
||||
'TaskResumeException': TaskResumeException.new,
|
||||
'TaskHttpException': TaskHttpException.new,
|
||||
};
|
||||
|
||||
/// Contains Exception information associated with a failed [Task]
|
||||
///
|
||||
/// The [exceptionType] categorizes and describes the exception
|
||||
/// The [description] is typically taken from the platform-generated
|
||||
/// exception message, or from the plugin. The localization is undefined
|
||||
/// For the [TaskHttpException], the [httpResponseCode] is only valid if >0
|
||||
/// and may offer details about the nature of the error
|
||||
base class TaskException(final String description) implements Exception {
|
||||
String get exceptionType => 'TaskException';
|
||||
|
||||
/// Create object from [json]
|
||||
factory TaskException.fromJson(Map<String, dynamic> json) {
|
||||
final typeString = json['type'] as String? ?? 'TaskException';
|
||||
final exceptionType = _exceptions[typeString];
|
||||
final description = json['description'] as String? ?? '';
|
||||
if (exceptionType != null) {
|
||||
final httpResponseCode =
|
||||
(json['httpResponseCode'] as num?)?.toInt() ?? -1;
|
||||
return typeString != 'TaskHttpException'
|
||||
? exceptionType(description)
|
||||
: exceptionType(description, httpResponseCode);
|
||||
}
|
||||
return TaskException('Unknown');
|
||||
}
|
||||
|
||||
/// Create object from String description of the type, and parameters
|
||||
factory TaskException.fromTypeString(
|
||||
String typeString,
|
||||
String description, [
|
||||
int httpResponseCode = -1,
|
||||
]) {
|
||||
final exceptionType = _exceptions[typeString] ?? TaskException.new;
|
||||
return typeString != 'TaskHttpException'
|
||||
? exceptionType(description)
|
||||
: exceptionType(description, httpResponseCode);
|
||||
}
|
||||
|
||||
/// Return JSON Map representing object
|
||||
Map<String, dynamic> toJson() => {
|
||||
'type': exceptionType,
|
||||
'description': description,
|
||||
};
|
||||
|
||||
/// Return JSON String representing object
|
||||
String toJsonString() => jsonEncode(toJson());
|
||||
|
||||
@override
|
||||
String toString() => '$exceptionType: $description';
|
||||
}
|
||||
|
||||
/// Exception related to the filesystem, e.g. insufficient space
|
||||
/// or file not found
|
||||
final class TaskFileSystemException(super.description) extends TaskException {
|
||||
@override
|
||||
String get exceptionType => 'TaskFileSystemException';
|
||||
}
|
||||
|
||||
/// Exception related to the url, eg malformed
|
||||
final class TaskUrlException(super.description) extends TaskException {
|
||||
@override
|
||||
String get exceptionType => 'TaskUrlException';
|
||||
}
|
||||
|
||||
/// Exception related to the connection, e.g. socket exception
|
||||
/// or request timeout
|
||||
final class TaskConnectionException(super.description) extends TaskException {
|
||||
@override
|
||||
String get exceptionType => 'TaskConnectionException';
|
||||
}
|
||||
|
||||
/// Exception related to an attempt to resume a task, e.g.
|
||||
/// the temp filename no longer exists, or eTag has changed
|
||||
final class TaskResumeException(super.description) extends TaskException {
|
||||
@override
|
||||
String get exceptionType => 'TaskResumeException';
|
||||
}
|
||||
|
||||
/// Exception related to the HTTP response, e.g. a 403
|
||||
/// response code
|
||||
final class TaskHttpException(super.description, final int httpResponseCode)
|
||||
extends TaskException {
|
||||
@override
|
||||
String get exceptionType => 'TaskHttpException';
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {
|
||||
...super.toJson(),
|
||||
'httpResponseCode': httpResponseCode,
|
||||
};
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'$exceptionType, response code $httpResponseCode: $description';
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,243 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'base_downloader.dart';
|
||||
import 'models.dart';
|
||||
import 'task.dart';
|
||||
|
||||
/// Commands to be executed by the background isolate
|
||||
sealed class JsonCommand {
|
||||
final int id;
|
||||
const JsonCommand(this.id);
|
||||
}
|
||||
|
||||
class _TaskFromJson extends JsonCommand {
|
||||
final String jsonString;
|
||||
const _TaskFromJson(super.id, this.jsonString);
|
||||
}
|
||||
|
||||
class _DownloadTaskListFromJson extends JsonCommand {
|
||||
final String jsonString;
|
||||
const _DownloadTaskListFromJson(super.id, this.jsonString);
|
||||
}
|
||||
|
||||
class _TaskListFromListStrings extends JsonCommand {
|
||||
final List<dynamic> jsonStrings;
|
||||
const _TaskListFromListStrings(super.id, this.jsonStrings);
|
||||
}
|
||||
|
||||
class _TaskAndNotificationConfigJsonStrings extends JsonCommand {
|
||||
final List<Task> tasks;
|
||||
final Set<TaskNotificationConfig> notificationConfigs;
|
||||
const _TaskAndNotificationConfigJsonStrings(
|
||||
super.id,
|
||||
this.tasks,
|
||||
this.notificationConfigs,
|
||||
);
|
||||
}
|
||||
|
||||
/// Singleton object that manages a background isolate for JSON encoding/decoding
|
||||
class JsonProcessor {
|
||||
static final JsonProcessor _instance = JsonProcessor._internal();
|
||||
|
||||
factory JsonProcessor() => _instance;
|
||||
|
||||
JsonProcessor._internal();
|
||||
|
||||
Isolate? _isolate;
|
||||
SendPort? _sendPort;
|
||||
final Map<int, Completer<dynamic>> _pendingCompleters = {};
|
||||
Timer? _shutdownTimer;
|
||||
int _nextId = 0;
|
||||
bool _isStarting = false;
|
||||
Completer<void> _startCompleter = Completer<void>();
|
||||
|
||||
/// Public API
|
||||
|
||||
Future<Task> decodeTask(String jsonString) async =>
|
||||
await _process<Task>((id) => _TaskFromJson(id, jsonString));
|
||||
|
||||
Future<List<DownloadTask>> decodeDownloadTaskList(String jsonString) async =>
|
||||
await _process<List<DownloadTask>>(
|
||||
(id) => _DownloadTaskListFromJson(id, jsonString),
|
||||
);
|
||||
|
||||
Future<List<Task>> decodeTaskList(List<dynamic> jsonStrings) async =>
|
||||
await _process<List<Task>>(
|
||||
(id) => _TaskListFromListStrings(id, jsonStrings),
|
||||
);
|
||||
|
||||
Future<(String, String)> encodeTaskAndNotificationConfig(
|
||||
Iterable<Task> tasks,
|
||||
Set<TaskNotificationConfig> notificationConfigs,
|
||||
) async {
|
||||
// Convert Iterable to List to ensure it's sendable and fixed
|
||||
final taskList = tasks.toList();
|
||||
return await _process<(String, String)>(
|
||||
(id) => _TaskAndNotificationConfigJsonStrings(
|
||||
id,
|
||||
taskList,
|
||||
notificationConfigs,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Internal processing logic
|
||||
|
||||
Future<T> _process<T>(JsonCommand Function(int id) commandBuilder) async {
|
||||
await _ensureStarted();
|
||||
_resetShutdownTimer();
|
||||
|
||||
final id = _nextId++;
|
||||
final completer = Completer<T>();
|
||||
_pendingCompleters[id] = completer;
|
||||
|
||||
final command = commandBuilder(id);
|
||||
_sendPort!.send(command);
|
||||
|
||||
try {
|
||||
final result = await completer.future;
|
||||
return result;
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
} finally {
|
||||
_resetShutdownTimer(); // Reset timer on completion too
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ensureStarted() async {
|
||||
if (_isolate != null && _sendPort != null) return;
|
||||
|
||||
if (_isStarting) {
|
||||
await _startCompleter.future;
|
||||
return;
|
||||
}
|
||||
|
||||
_isStarting = true;
|
||||
_startCompleter = Completer<void>();
|
||||
|
||||
try {
|
||||
final receivePort = ReceivePort();
|
||||
_isolate = await Isolate.spawn(_isolateMain, receivePort.sendPort);
|
||||
|
||||
// Wait for the isolate to send its SendPort
|
||||
_sendPort = await receivePort.first as SendPort;
|
||||
|
||||
// Create a new receive port for responses
|
||||
final responsePort = ReceivePort();
|
||||
_sendPort!.send(responsePort.sendPort);
|
||||
|
||||
responsePort.listen(_handleResponse);
|
||||
|
||||
if (!_startCompleter.isCompleted) {
|
||||
_startCompleter.complete();
|
||||
}
|
||||
} catch (e) {
|
||||
_isolate?.kill();
|
||||
_isolate = null;
|
||||
_sendPort = null;
|
||||
_isStarting = false;
|
||||
if (!_startCompleter.isCompleted) {
|
||||
_startCompleter.completeError(e);
|
||||
}
|
||||
rethrow;
|
||||
} finally {
|
||||
_isStarting = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleResponse(dynamic message) {
|
||||
if (message is _JsonResponse) {
|
||||
final completer = _pendingCompleters.remove(message.id);
|
||||
if (completer != null) {
|
||||
if (message.error != null) {
|
||||
completer.completeError(message.error!);
|
||||
} else {
|
||||
completer.complete(message.result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _resetShutdownTimer() {
|
||||
_shutdownTimer?.cancel();
|
||||
_shutdownTimer = Timer(const Duration(minutes: 1), _shutdown);
|
||||
}
|
||||
|
||||
void _shutdown() {
|
||||
if (_pendingCompleters.isNotEmpty) {
|
||||
// Defer shutdown while requests are still in flight
|
||||
_resetShutdownTimer();
|
||||
return;
|
||||
}
|
||||
_isolate?.kill();
|
||||
_isolate = null;
|
||||
_sendPort = null;
|
||||
_shutdownTimer?.cancel();
|
||||
_shutdownTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
class _JsonResponse {
|
||||
final int id;
|
||||
final dynamic result;
|
||||
final Object? error;
|
||||
|
||||
_JsonResponse(this.id, this.result, this.error);
|
||||
}
|
||||
|
||||
void _isolateMain(SendPort mainSendPort) {
|
||||
final receivePort = ReceivePort();
|
||||
mainSendPort.send(receivePort.sendPort);
|
||||
|
||||
SendPort? replyPort;
|
||||
|
||||
receivePort.listen((message) async {
|
||||
if (message is SendPort) {
|
||||
replyPort = message;
|
||||
} else if (message is JsonCommand) {
|
||||
if (replyPort == null) {
|
||||
return; // Should not happen if protocol is followed
|
||||
}
|
||||
|
||||
try {
|
||||
final result = await _executeCommand(message);
|
||||
replyPort!.send(_JsonResponse(message.id, result, null));
|
||||
} catch (e) {
|
||||
replyPort!.send(_JsonResponse(message.id, null, e));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<dynamic> _executeCommand(JsonCommand command) async {
|
||||
switch (command) {
|
||||
case final _TaskFromJson c:
|
||||
return Task.createFromJson(jsonDecode(c.jsonString));
|
||||
|
||||
case final _DownloadTaskListFromJson c:
|
||||
return (jsonDecode(c.jsonString) as List)
|
||||
.map((e) => Task.createFromJson(e as Map<String, dynamic>))
|
||||
.cast<DownloadTask>()
|
||||
.toList();
|
||||
|
||||
case final _TaskListFromListStrings c:
|
||||
return c.jsonStrings
|
||||
.map((e) => Task.createFromJson(jsonDecode(e as String)))
|
||||
.toList();
|
||||
|
||||
case final _TaskAndNotificationConfigJsonStrings c:
|
||||
final tasksJsonString = jsonEncode(c.tasks);
|
||||
final configs = c.tasks
|
||||
.map(
|
||||
(task) => BaseDownloader.notificationConfigForTaskUsingConfigSet(
|
||||
task,
|
||||
c.notificationConfigs,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
final notificationConfigsJsonString = jsonEncode(configs);
|
||||
return (tasksJsonString, notificationConfigsJsonString);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
part of 'localstore.dart';
|
||||
|
||||
/// A [CollectionRef] object can be used for adding documents, getting
|
||||
/// [DocumentRef]s, and querying for documents.
|
||||
final class CollectionRef implements CollectionRefImpl {
|
||||
String _id;
|
||||
|
||||
/// A string representing the path of the referenced document (relative to the
|
||||
/// root of the database).
|
||||
String get path => _path;
|
||||
|
||||
String _path = '';
|
||||
|
||||
DocumentRef? _delegate;
|
||||
|
||||
CollectionRef? _parent;
|
||||
|
||||
List<List>? _conditions;
|
||||
|
||||
static final pathSeparatorRegEx = RegExp(r'[/\\]');
|
||||
|
||||
/// The parent [CollectionRef] of this document.
|
||||
CollectionRef? get parent => _parent;
|
||||
|
||||
CollectionRef._(this._id, [this._parent, this._delegate, this._conditions]) {
|
||||
_path = _buildPath(_parent?.path, _id, _delegate?.id);
|
||||
}
|
||||
static final _cache = <String, CollectionRef>{};
|
||||
|
||||
/// Returns an instance using the default [CollectionRef].
|
||||
factory CollectionRef(
|
||||
String id, [
|
||||
CollectionRef? parent,
|
||||
DocumentRef? delegate,
|
||||
List<List>? conditions,
|
||||
]) {
|
||||
final key = _buildPath(parent?.path, id, delegate?.id);
|
||||
final collectionRef = _cache.putIfAbsent(
|
||||
key,
|
||||
() => CollectionRef._(id, parent, delegate, conditions),
|
||||
);
|
||||
collectionRef._conditions = conditions;
|
||||
return collectionRef;
|
||||
}
|
||||
|
||||
static String _buildPath(String? parentPath, String path, String? docId) {
|
||||
final docPath = ((docId != null && parentPath != null)
|
||||
? '$docId.collection'
|
||||
: '');
|
||||
final pathSep = p.separator;
|
||||
return '${parentPath ?? ''}$docPath$pathSep$path$pathSep';
|
||||
}
|
||||
|
||||
final _utils = Utils.instance;
|
||||
|
||||
@override
|
||||
Stream<Map<String, dynamic>> get stream => _utils.stream(path, _conditions);
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>?> get() async =>
|
||||
await _utils.get(path, true, _conditions);
|
||||
|
||||
@override
|
||||
DocumentRef doc([String? id]) {
|
||||
id ??= int.parse(
|
||||
'${Random().nextInt(1000000000)}${Random().nextInt(1000000000)}',
|
||||
).toRadixString(35).substring(0, 9);
|
||||
return DocumentRef(id, this);
|
||||
}
|
||||
|
||||
@override
|
||||
CollectionRef where(field, {isEqualTo}) {
|
||||
final conditions = <List>[];
|
||||
void addCondition(dynamic field, String operator, dynamic value) {
|
||||
List<dynamic> condition;
|
||||
|
||||
condition = <dynamic>[field, operator, value];
|
||||
conditions.add(condition);
|
||||
}
|
||||
|
||||
if (isEqualTo != null) addCondition(field, '==', isEqualTo);
|
||||
|
||||
_conditions = conditions;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete() async {
|
||||
final docs = await _utils.get(path, true, _conditions);
|
||||
if (docs != null) {
|
||||
for (final key in docs.keys) {
|
||||
final id = key.split(pathSeparatorRegEx).last;
|
||||
DocumentRef(id, this)._data.clear();
|
||||
}
|
||||
}
|
||||
|
||||
await _utils.delete(path);
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
part of 'localstore.dart';
|
||||
|
||||
/// The interface that other CollectionRef must extend.
|
||||
abstract class CollectionRefImpl {
|
||||
/// Returns a `DocumentRef` with the provided id.
|
||||
///
|
||||
/// If no [id] is provided, an auto-generated ID is used.
|
||||
///
|
||||
/// The unique key generated is prefixed with a client-generated timestamp
|
||||
/// so that the resulting list will be chronologically-sorted.
|
||||
DocumentRef doc([String? id]);
|
||||
|
||||
/// Notifies of query results at this collection.
|
||||
Stream<Map<String, dynamic>> get stream;
|
||||
|
||||
/// Fetch the documents for this collection
|
||||
Future<Map<String, dynamic>?> get();
|
||||
|
||||
/// Creates and returns a new [CollectionRef] with additional filter on
|
||||
/// specified [field]. [field] refers to a field in a document.
|
||||
///
|
||||
/// `where` is not implemented
|
||||
CollectionRef where(dynamic field, {dynamic isEqualTo});
|
||||
|
||||
/// Delete collection
|
||||
///
|
||||
/// All collections and documents in this collection will be deleted.
|
||||
Future<void> delete();
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
part of 'localstore.dart';
|
||||
|
||||
/// A [DocumentRef] refers to a document location in a [Localstore] database
|
||||
/// and can be used to write, read, or listen to the location.
|
||||
///
|
||||
/// The document at the referenced location may or may not exist.
|
||||
/// A [DocumentRef] can also be used to create a [CollectionRef]
|
||||
/// to a subcollection.
|
||||
final class DocumentRef implements DocumentRefImpl {
|
||||
String _id;
|
||||
|
||||
/// This document's given ID within the collection.
|
||||
String get id => _id;
|
||||
|
||||
CollectionRef? _delegate;
|
||||
|
||||
DocumentRef._(this._id, [this._delegate]);
|
||||
|
||||
static final _cache = <String, DocumentRef>{};
|
||||
|
||||
/// Returns an instance using the default [DocumentRef].
|
||||
factory DocumentRef(String id, [CollectionRef? delegate]) {
|
||||
final key = '${delegate?.path ?? ''}$id';
|
||||
return _cache.putIfAbsent(key, () => DocumentRef._(id, delegate));
|
||||
}
|
||||
|
||||
/// A string representing the path of the referenced document (relative to the
|
||||
/// root of the database).
|
||||
String get path => '${_delegate?.path}$id';
|
||||
|
||||
final _utils = Utils.instance;
|
||||
|
||||
final Map<String, dynamic> _data = {};
|
||||
|
||||
@override
|
||||
Future<dynamic> set(Map<String, dynamic> data, [SetOptions? options]) async {
|
||||
options ??= SetOptions();
|
||||
if (options.merge) {
|
||||
final output = Map<String, dynamic>.from(data);
|
||||
final Map<String, dynamic>? input = _data[id] ?? {};
|
||||
output.updateAll((key, value) {
|
||||
input![key] = value;
|
||||
});
|
||||
_data[id] = input;
|
||||
} else {
|
||||
_data[id] = data;
|
||||
}
|
||||
return await _utils.set(_data[id], path);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>?> get() async =>
|
||||
_data[id] ?? await _utils.get(path);
|
||||
|
||||
@override
|
||||
Future delete() async {
|
||||
await _utils.delete(path);
|
||||
_data.remove(id);
|
||||
}
|
||||
|
||||
@override
|
||||
CollectionRef collection(String id) => CollectionRef(id, _delegate, this);
|
||||
|
||||
@override
|
||||
String toString() => _utils.toString();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
part of 'localstore.dart';
|
||||
|
||||
/// The interface that other DocumentRef must extend.
|
||||
abstract class DocumentRefImpl {
|
||||
/// Gets a [CollectionRef] for the specified Localstore path.
|
||||
CollectionRef collection(String path);
|
||||
|
||||
/// Sets data on the document, overwriting any existing data. If the document
|
||||
/// does not yet exist, it will be created.
|
||||
///
|
||||
/// If [SetOptions] are provided, the data will be merged into an existing
|
||||
/// document instead of overwriting.
|
||||
Future<dynamic> set(Map<String, dynamic> data, [SetOptions? options]);
|
||||
|
||||
/// Reads the document referenced by this [DocumentRef].
|
||||
Future<Map<String, dynamic>?> get();
|
||||
|
||||
/// Deletes the current document from the collection.
|
||||
Future delete();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'dart:math';
|
||||
|
||||
import 'utils/html.dart' if (dart.library.io) 'utils/io.dart';
|
||||
|
||||
part 'collection_ref.dart';
|
||||
part 'collection_ref_impl.dart';
|
||||
part 'document_ref.dart';
|
||||
part 'document_ref_impl.dart';
|
||||
part 'set_option.dart';
|
||||
part 'localstore_base.dart';
|
||||
part 'localstore_impl.dart';
|
||||
@@ -0,0 +1,30 @@
|
||||
part of 'localstore.dart';
|
||||
|
||||
/// The entry point for accessing a [Localstore].
|
||||
///
|
||||
/// You can get an instance by calling [Localstore.instance], for example:
|
||||
///
|
||||
/// ```dart
|
||||
/// final db = Localstore.instance;
|
||||
/// ```
|
||||
final class Localstore implements LocalstoreImpl {
|
||||
final _databaseDirectory = getApplicationSupportDirectory();
|
||||
final _delegate = DocumentRef._('');
|
||||
static final Localstore _localstore = Localstore._();
|
||||
|
||||
/// Private initializer
|
||||
Localstore._();
|
||||
|
||||
/// Returns an instance using the default [Localstore].
|
||||
static Localstore get instance => _localstore;
|
||||
|
||||
Future<Directory> get databaseDirectory => _databaseDirectory;
|
||||
|
||||
/// Clears the cache - needed only if filesystem has been manipulated directly
|
||||
void clearCache() {
|
||||
Utils.instance.clearCache();
|
||||
}
|
||||
|
||||
@override
|
||||
CollectionRef collection(String path) => CollectionRef(path, null, _delegate);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
part of 'localstore.dart';
|
||||
|
||||
/// The interface that other Localstore must extend.
|
||||
abstract class LocalstoreImpl {
|
||||
/// Gets a [CollectionRef] for the specified Localstore path.
|
||||
CollectionRef collection(String path);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
part of 'localstore.dart';
|
||||
|
||||
/// An options class that configures the behavior of set() calls in
|
||||
/// [DocumentRef].
|
||||
final class SetOptions {
|
||||
final bool _merge;
|
||||
|
||||
/// Changes the behavior of a set() call to only replace the values specified
|
||||
/// in its data argument.
|
||||
bool get merge => _merge;
|
||||
|
||||
/// Creates a [SetOptions] instance.
|
||||
SetOptions({this._merge = false});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'utils_impl.dart';
|
||||
|
||||
/// Utils class
|
||||
class Utils implements UtilsImpl {
|
||||
Utils._();
|
||||
|
||||
static final Utils _utils = Utils._();
|
||||
|
||||
static Utils get instance => _utils;
|
||||
|
||||
@override
|
||||
void clearCache() {
|
||||
throw UnimplementedError('Web is not supported');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>?> get(
|
||||
String path, [
|
||||
bool? isCollection = false,
|
||||
List<List>? conditions,
|
||||
]) async {
|
||||
throw UnimplementedError('Web is not supported');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<dynamic>? set(Map<String, dynamic> data, String path) {
|
||||
throw UnimplementedError('Web is not supported');
|
||||
}
|
||||
|
||||
@override
|
||||
Future delete(String path) async {
|
||||
throw UnimplementedError('Web is not supported');
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Map<String, dynamic>> stream(String path, [List<List>? conditions]) {
|
||||
throw UnimplementedError('Web is not supported');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../localstore.dart';
|
||||
import 'utils_impl.dart';
|
||||
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
final _log = Logger('Localstore');
|
||||
|
||||
final class Utils implements UtilsImpl {
|
||||
Utils._();
|
||||
|
||||
static final Utils _utils = Utils._();
|
||||
static final lastPathComponentRegEx = RegExp(r'[^/\\]+[/\\]?$');
|
||||
|
||||
static Utils get instance => _utils;
|
||||
final _storageCache = <String, StreamController<Map<String, dynamic>>>{};
|
||||
final _fileCache = <String, File>{};
|
||||
final _locks = <String, Future<void>>{};
|
||||
|
||||
/// Clears the cache
|
||||
@override
|
||||
void clearCache() {
|
||||
_storageCache.clear();
|
||||
_fileCache.clear();
|
||||
}
|
||||
|
||||
Future<T> _synchronized<T>(String path, Future<T> Function() action) async {
|
||||
final previous = _locks[path] ?? Future.value();
|
||||
final controller = Completer<T>();
|
||||
|
||||
final newFuture = previous
|
||||
.then((_) async {
|
||||
try {
|
||||
final result = await action();
|
||||
controller.complete(result);
|
||||
} catch (e, st) {
|
||||
controller.completeError(e, st);
|
||||
}
|
||||
})
|
||||
.catchError((_) {});
|
||||
|
||||
_locks[path] = newFuture;
|
||||
|
||||
// Cleanup
|
||||
newFuture.whenComplete(() {
|
||||
if (_locks[path] == newFuture) {
|
||||
_locks.remove(path);
|
||||
}
|
||||
});
|
||||
|
||||
return controller.future;
|
||||
}
|
||||
|
||||
String _resolvePath(String dbPath, String path) {
|
||||
var relativePath = path;
|
||||
if (relativePath.startsWith('/') || relativePath.startsWith('\\')) {
|
||||
relativePath = relativePath.substring(1);
|
||||
}
|
||||
return p.join(dbPath, relativePath);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>?> get(
|
||||
String path, [
|
||||
bool? isCollection = false,
|
||||
List<List>? conditions,
|
||||
]) async {
|
||||
// Fetch the documents for this collection
|
||||
if (isCollection != null && isCollection == true) {
|
||||
final dbDir = await Localstore.instance.databaseDirectory;
|
||||
final fullPath = _resolvePath(dbDir.path, path);
|
||||
final dir = Directory(fullPath);
|
||||
if (!await dir.exists()) {
|
||||
return {};
|
||||
}
|
||||
final entries = await dir.list(recursive: false).toList();
|
||||
return await _getAll(entries.whereType<File>().toList());
|
||||
} else {
|
||||
return _synchronized(path, () async {
|
||||
try {
|
||||
// Reads the document referenced by this [DocumentRef].
|
||||
final file = await _getFile(path);
|
||||
final randomAccessFile = await file!.open(mode: FileMode.append);
|
||||
try {
|
||||
final data = await _readFile(randomAccessFile);
|
||||
if (data is Map<String, dynamic>) {
|
||||
final key = path.replaceAll(lastPathComponentRegEx, '');
|
||||
// ignore: close_sinks
|
||||
final storage = _storageCache.putIfAbsent(
|
||||
key,
|
||||
() => _newStream(key),
|
||||
);
|
||||
storage.add(data);
|
||||
return data;
|
||||
}
|
||||
} finally {
|
||||
await randomAccessFile.close();
|
||||
}
|
||||
} on PathNotFoundException {
|
||||
// return null if not found
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<dynamic>? set(Map<String, dynamic> data, String path) =>
|
||||
_writeFile(data, path);
|
||||
|
||||
@override
|
||||
Future delete(String path) async {
|
||||
if (path.endsWith(Platform.pathSeparator)) {
|
||||
await _deleteDirectory(path);
|
||||
} else {
|
||||
await _synchronized(path, () => _deleteFile(path));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Map<String, dynamic>> stream(String path, [List<List>? conditions]) {
|
||||
// ignore: close_sinks
|
||||
var storage = _storageCache[path];
|
||||
if (storage == null) {
|
||||
storage = _storageCache.putIfAbsent(path, () => _newStream(path));
|
||||
} else {
|
||||
_initStream(storage, path);
|
||||
}
|
||||
return storage.stream;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> _getAll(List<FileSystemEntity> entries) async {
|
||||
_log.finest('Getting all entries from ${entries.length} files');
|
||||
final items = <String, dynamic>{};
|
||||
final dbDir = await Localstore.instance.databaseDirectory;
|
||||
for (final e in entries) {
|
||||
final relativePath = p.relative(e.path, from: dbDir.path);
|
||||
final path = Platform.isWindows
|
||||
? relativePath.replaceAll(p.separator, '/')
|
||||
: relativePath;
|
||||
|
||||
await _synchronized(path, () async {
|
||||
final file = await _getFile(path);
|
||||
try {
|
||||
final randomAccessFile = await file!.open(mode: FileMode.append);
|
||||
try {
|
||||
final data = await _readFile(randomAccessFile);
|
||||
if (data is Map<String, dynamic>) {
|
||||
items[path] = data;
|
||||
}
|
||||
} finally {
|
||||
await randomAccessFile.close();
|
||||
}
|
||||
} on PathNotFoundException {
|
||||
// ignore if not found
|
||||
} catch (e) {
|
||||
_log.warning('Error reading file $path: $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (items.isEmpty) return null;
|
||||
return items;
|
||||
}
|
||||
|
||||
/// Streams all file in the path
|
||||
StreamController<Map<String, dynamic>> _newStream(String path) {
|
||||
final storage = StreamController<Map<String, dynamic>>.broadcast();
|
||||
_initStream(storage, path);
|
||||
|
||||
return storage;
|
||||
}
|
||||
|
||||
Future _initStream(
|
||||
StreamController<Map<String, dynamic>> storage,
|
||||
String path,
|
||||
) async {
|
||||
final dbDir = await Localstore.instance.databaseDirectory;
|
||||
final fullPath = _resolvePath(dbDir.path, path);
|
||||
final dir = Directory(fullPath);
|
||||
try {
|
||||
if (!await dir.exists()) return;
|
||||
final entries = await dir.list(recursive: false).toList();
|
||||
for (final e in entries) {
|
||||
if (e is! File) continue;
|
||||
final relativePath = p.relative(e.path, from: dbDir.path);
|
||||
final filePath = Platform.isWindows
|
||||
? relativePath.replaceAll(p.separator, '/')
|
||||
: relativePath;
|
||||
|
||||
// We use synchronized reading
|
||||
await _synchronized(filePath, () async {
|
||||
final file = await _getFile(filePath);
|
||||
final randomAccessFile = await file!.open(mode: FileMode.append);
|
||||
try {
|
||||
final data = await _readFile(randomAccessFile);
|
||||
if (data is Map<String, dynamic>) {
|
||||
storage.add(data);
|
||||
}
|
||||
} finally {
|
||||
await randomAccessFile.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
Future<dynamic> _readFile(RandomAccessFile file) async {
|
||||
final length = await file.length();
|
||||
await file.setPosition(0);
|
||||
final buffer = Uint8List(length);
|
||||
await file.readInto(buffer);
|
||||
try {
|
||||
final contentText = utf8.decode(buffer);
|
||||
final data = json.decode(contentText) as Map<String, dynamic>;
|
||||
return data;
|
||||
} catch (e) {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
|
||||
Future<File?> _getFile(String path) async {
|
||||
final dbDir = await Localstore.instance.databaseDirectory;
|
||||
final file = _fileCache[path] ?? File(_resolvePath(dbDir.path, path));
|
||||
|
||||
if (!await file.exists()) {
|
||||
try {
|
||||
await file.create(recursive: true);
|
||||
} on FileSystemException {
|
||||
// Handle race condition where directory was being deleted/modified concurrently
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
if (!await file.exists()) {
|
||||
try {
|
||||
await file.create(recursive: true);
|
||||
} on FileSystemException catch (e) {
|
||||
_log.warning('Could not create file $path: $e');
|
||||
_fileCache.remove(path);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_fileCache[path] = file;
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
Future _writeFile(Map<String, dynamic> data, String path) =>
|
||||
_synchronized(path, () async {
|
||||
final serialized = json.encode(data);
|
||||
final buffer = utf8.encode(serialized);
|
||||
try {
|
||||
final file = await _getFile(path);
|
||||
if (file == null) return;
|
||||
final randomAccessFile = await file.open(mode: FileMode.append);
|
||||
try {
|
||||
await randomAccessFile.lock();
|
||||
await randomAccessFile.setPosition(0);
|
||||
await randomAccessFile.writeFrom(buffer);
|
||||
await randomAccessFile.truncate(buffer.length);
|
||||
await randomAccessFile.unlock();
|
||||
} finally {
|
||||
await randomAccessFile.close();
|
||||
}
|
||||
} on FileSystemException catch (e) {
|
||||
_log.warning('FileSystemException writing file $path: $e');
|
||||
return;
|
||||
} catch (e) {
|
||||
_log.warning('Error writing file $path: $e');
|
||||
return;
|
||||
}
|
||||
final key = path.replaceAll(lastPathComponentRegEx, '');
|
||||
// ignore: close_sinks
|
||||
final storage = _storageCache.putIfAbsent(key, () => _newStream(key));
|
||||
storage.add(data);
|
||||
});
|
||||
|
||||
Future _deleteFile(String path) async {
|
||||
final dbDir = await Localstore.instance.databaseDirectory;
|
||||
final file = File(_resolvePath(dbDir.path, path));
|
||||
if (await file.exists()) {
|
||||
try {
|
||||
await file.delete();
|
||||
} catch (e) {
|
||||
_log.finest(e);
|
||||
}
|
||||
}
|
||||
_fileCache.remove(path);
|
||||
}
|
||||
|
||||
Future _deleteDirectory(String path) async {
|
||||
final dbDir = await Localstore.instance.databaseDirectory;
|
||||
final dir = Directory(_resolvePath(dbDir.path, path));
|
||||
_fileCache.removeWhere((key, value) => key.startsWith(path));
|
||||
if (await dir.exists()) {
|
||||
try {
|
||||
await dir.delete(recursive: true);
|
||||
} catch (e) {
|
||||
_log.finest(e);
|
||||
}
|
||||
}
|
||||
_fileCache.removeWhere((key, value) => key.startsWith(path));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
abstract class UtilsImpl {
|
||||
void clearCache();
|
||||
Future<Map<String, dynamic>?> get(
|
||||
String path, [
|
||||
bool? isCollection = false,
|
||||
List<List>? conditions,
|
||||
]);
|
||||
Future<dynamic>? set(Map<String, dynamic> data, String path);
|
||||
Future delete(String path);
|
||||
Stream<Map<String, dynamic>> stream(String path, [List<List>? conditions]);
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'exceptions.dart';
|
||||
import 'task.dart';
|
||||
|
||||
export 'options/mtls.dart';
|
||||
|
||||
/// Defines a set of possible states which a [Task] can be in.
|
||||
enum TaskStatus {
|
||||
/// Task is enqueued on the native platform and waiting to start
|
||||
///
|
||||
/// It may wait for resources, or for an appropriate network to become
|
||||
/// available before starting the actual download and changing state to
|
||||
/// `running`.
|
||||
enqueued,
|
||||
|
||||
/// Task is running, i.e. actively downloading
|
||||
running,
|
||||
|
||||
/// Task has completed successfully
|
||||
///
|
||||
/// This is a final state
|
||||
complete,
|
||||
|
||||
/// Task has completed because the url was not found (Http status code 404)
|
||||
///
|
||||
/// This is a final state
|
||||
notFound,
|
||||
|
||||
/// Task has failed due to an exception
|
||||
///
|
||||
/// This is a final state
|
||||
failed,
|
||||
|
||||
/// Task has been canceled by the user or the system
|
||||
///
|
||||
/// This is a final state
|
||||
canceled,
|
||||
|
||||
/// Task failed, and is now waiting to retry
|
||||
///
|
||||
/// The task is held in this state until the exponential backoff time for
|
||||
/// this retry has passed, and will then be rescheduled on the native
|
||||
/// platform, switching state to `enqueued` and then `running`
|
||||
waitingToRetry,
|
||||
|
||||
/// Task is in paused state and may be able to resume
|
||||
///
|
||||
/// To resume a paused Task, call [FileDownloader.resume]. If the resume is
|
||||
/// possible, status will change to [TaskStatus.running] and continue from
|
||||
/// there. If resume fails (e.g. because the temp file with the partial
|
||||
/// download has been deleted by the operating system) status will switch
|
||||
/// to [TaskStatus.failed]
|
||||
paused;
|
||||
|
||||
/// True if this state is one of the 'final' states, meaning no more
|
||||
/// state changes are possible
|
||||
bool get isFinalState => switch (this) {
|
||||
.complete || .notFound || .failed || .canceled => true,
|
||||
.enqueued || .running || .waitingToRetry || .paused => false,
|
||||
};
|
||||
|
||||
/// True if this state is not a 'final' state, meaning more
|
||||
/// state changes are possible
|
||||
bool get isNotFinalState => !isFinalState;
|
||||
}
|
||||
|
||||
/// Base directory in which files will be stored, based on their relative
|
||||
/// path.
|
||||
///
|
||||
/// These correspond to the directories provided by the path_provider package
|
||||
enum BaseDirectory {
|
||||
/// As returned by getApplicationDocumentsDirectory()
|
||||
applicationDocuments,
|
||||
|
||||
/// As returned by getTemporaryDirectory()
|
||||
temporary,
|
||||
|
||||
/// As returned by getApplicationSupportDirectory()
|
||||
applicationSupport,
|
||||
|
||||
/// As returned by getApplicationLibrary() on iOS. For other platforms
|
||||
/// this resolves to the subdirectory 'Library' created in the directory
|
||||
/// returned by getApplicationSupportDirectory()
|
||||
applicationLibrary,
|
||||
|
||||
/// System root directory. This allows you to set a path to any directory
|
||||
/// via [Task.directory]. Only use this if you are certain that this
|
||||
/// path is stable. on iOS and Android, references to paths within
|
||||
/// the application's directory structure are *not* stable, and you
|
||||
/// should use [applicationDocuments], [applicationSupport] or
|
||||
/// [applicationLibrary] instead to avoid errors.
|
||||
root,
|
||||
}
|
||||
|
||||
/// Type of updates requested for a task or group of tasks
|
||||
enum Updates {
|
||||
/// no status change or progress updates
|
||||
none,
|
||||
|
||||
/// only status changes
|
||||
status,
|
||||
|
||||
/// only progress updates while downloading, no status change updates
|
||||
progress,
|
||||
|
||||
/// Status change updates and progress updates while downloading
|
||||
statusAndProgress,
|
||||
}
|
||||
|
||||
/// Signature for a function you can register to be called
|
||||
/// when the status of a [Task] changes.
|
||||
typedef TaskStatusCallback = void Function(TaskStatusUpdate update);
|
||||
|
||||
/// Signature for a function you can register to be called
|
||||
/// for every progress change of a [Task].
|
||||
///
|
||||
/// A successfully completed task will always finish with progress 1.0
|
||||
/// [TaskStatus.failed] results in progress -1.0
|
||||
/// [TaskStatus.canceled] results in progress -2.0
|
||||
/// [TaskStatus.notFound] results in progress -3.0
|
||||
/// [TaskStatus.waitingToRetry] results in progress -4.0
|
||||
/// These constants are available as [progressFailed] etc
|
||||
typedef TaskProgressCallback = void Function(TaskProgressUpdate update);
|
||||
|
||||
/// Signature for function you can register to be called when a notification
|
||||
/// is tapped by the user
|
||||
typedef TaskNotificationTapCallback = void Function(
|
||||
Task task,
|
||||
NotificationType notificationType,
|
||||
);
|
||||
|
||||
/// Signature for a function you can provide to the [FileDownloader.downloadBatch] or
|
||||
/// [FileDownloader.uploadBatch] that will be called upon completion of each task
|
||||
/// in the batch.
|
||||
///
|
||||
/// [succeeded] will count the number of successful downloads, and
|
||||
/// [failed] counts the number of failed downloads (for any reason).
|
||||
typedef BatchProgressCallback = void Function(int succeeded, int failed);
|
||||
|
||||
/// Contains tasks and results related to a batch of tasks
|
||||
class Batch(
|
||||
final List<Task> tasks,
|
||||
final BatchProgressCallback? batchProgressCallback,
|
||||
) {
|
||||
final results = <Task, TaskStatus>{};
|
||||
|
||||
/// Returns an Iterable with successful tasks in this batch
|
||||
Iterable<Task> get succeeded => results.entries
|
||||
.where((entry) => entry.value == TaskStatus.complete)
|
||||
.map((e) => e.key);
|
||||
|
||||
/// Returns the number of successful tasks in this batch
|
||||
int get numSucceeded =>
|
||||
results.values.where((result) => result == TaskStatus.complete).length;
|
||||
|
||||
/// Returns an Iterable with failed tasks in this batch
|
||||
Iterable<Task> get failed => results.entries
|
||||
.where((entry) => entry.value != TaskStatus.complete)
|
||||
.map((e) => e.key);
|
||||
|
||||
/// Returns the number of failed downloads in this batch
|
||||
int get numFailed => results.values.length - numSucceeded;
|
||||
}
|
||||
|
||||
/// Base class for updates related to [task]. Actual updates are
|
||||
/// either a status update or a progress update.
|
||||
///
|
||||
/// When receiving an update, test if the update is a
|
||||
/// [TaskStatusUpdate] or a [TaskProgressUpdate]
|
||||
/// and treat the update accordingly
|
||||
sealed class TaskUpdate {
|
||||
final Task task;
|
||||
|
||||
const TaskUpdate(this.task);
|
||||
|
||||
/// Create object from [json]
|
||||
TaskUpdate.fromJson(Map<String, dynamic> json)
|
||||
: task = Task.createFromJson(json['task'] ?? json);
|
||||
|
||||
/// Return JSON Map representing object
|
||||
Map<String, dynamic> toJson() => {'task': task.toJson()};
|
||||
}
|
||||
|
||||
/// A status update
|
||||
///
|
||||
/// Contains [TaskStatus] and, if [TaskStatus.failed] possibly a
|
||||
/// [TaskException] and if this is a final state possibly [responseBody],
|
||||
/// [responseHeaders], [responseStatusCode], [mimeType] and [charSet].
|
||||
/// Note: header names in [responseHeaders] are converted to lowercase
|
||||
class TaskStatusUpdate extends TaskUpdate {
|
||||
final TaskStatus status; // note: serialized as 'taskStatus'
|
||||
final TaskException? exception;
|
||||
final String? responseBody;
|
||||
final int? responseStatusCode;
|
||||
final Map<String, String>? responseHeaders;
|
||||
final String? mimeType; // derived from Content-Type header
|
||||
final String? charSet; // derived from Content-Type header
|
||||
|
||||
const TaskStatusUpdate(
|
||||
super.task,
|
||||
this.status, [
|
||||
this.exception,
|
||||
this.responseBody,
|
||||
this.responseHeaders,
|
||||
this.responseStatusCode,
|
||||
this.mimeType,
|
||||
this.charSet,
|
||||
]);
|
||||
|
||||
/// Create object from [json]
|
||||
TaskStatusUpdate.fromJson(super.json)
|
||||
: status = TaskStatus.values[(json['taskStatus'] as num?)?.toInt() ?? 0],
|
||||
exception = json['exception'] != null
|
||||
? TaskException.fromJson(json['exception'])
|
||||
: null,
|
||||
responseBody = json['responseBody'],
|
||||
responseHeaders = json['responseHeaders'] != null
|
||||
? Map.from(json['responseHeaders'])
|
||||
: null,
|
||||
responseStatusCode = (json['responseStatusCode'] as num?)?.toInt(),
|
||||
mimeType = json['mimeType'],
|
||||
charSet = json['charSet'],
|
||||
super.fromJson();
|
||||
|
||||
/// Create object from [jsonString]
|
||||
factory TaskStatusUpdate.fromJsonString(String jsonString) =>
|
||||
TaskStatusUpdate.fromJson(jsonDecode(jsonString));
|
||||
|
||||
/// Return JSON Map representing object
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {
|
||||
...super.toJson(),
|
||||
'taskStatus': status.index,
|
||||
'exception': exception?.toJson(),
|
||||
'responseBody': responseBody,
|
||||
'responseHeaders': responseHeaders,
|
||||
'responseStatusCode': responseStatusCode,
|
||||
'mimeType': mimeType,
|
||||
'charSet': charSet,
|
||||
};
|
||||
|
||||
TaskStatusUpdate copyWith({
|
||||
Task? task,
|
||||
TaskStatus? status,
|
||||
TaskException? exception,
|
||||
String? responseBody,
|
||||
Map<String, String>? responseHeaders,
|
||||
int? responseStatusCode,
|
||||
String? mimeType,
|
||||
String? charSet,
|
||||
}) => TaskStatusUpdate(
|
||||
task ?? this.task,
|
||||
status ?? this.status,
|
||||
exception ?? this.exception,
|
||||
responseBody ?? this.responseBody,
|
||||
responseHeaders ?? this.responseHeaders,
|
||||
responseStatusCode ?? this.responseStatusCode,
|
||||
mimeType ?? this.mimeType,
|
||||
charSet ?? this.charSet,
|
||||
);
|
||||
}
|
||||
|
||||
/// A progress update
|
||||
///
|
||||
/// A successfully downloaded task will always finish with progress 1.0
|
||||
///
|
||||
/// [TaskStatus.failed] results in progress -1.0
|
||||
/// [TaskStatus.canceled] results in progress -2.0
|
||||
/// [TaskStatus.notFound] results in progress -3.0
|
||||
/// [TaskStatus.waitingToRetry] results in progress -4.0
|
||||
///
|
||||
/// [expectedFileSize] will only be representative if the 0 < [progress] < 1,
|
||||
/// so NOT representative when progress == 0 or progress == 1, and
|
||||
/// will be -1 if the file size is not provided by the server or otherwise
|
||||
/// not known.
|
||||
/// [networkSpeed] is valid if positive, expressed in MB/second
|
||||
/// [timeRemaining] is valid if positive
|
||||
///
|
||||
/// Use the [hasExpectedFileSize], [hasNetworkSpeed] and [hasTimeRemaining]
|
||||
/// getters to determine whether a field is valid
|
||||
class TaskProgressUpdate extends TaskUpdate {
|
||||
final double progress;
|
||||
final int expectedFileSize;
|
||||
final double networkSpeed; // in MB/s
|
||||
final Duration timeRemaining;
|
||||
|
||||
const TaskProgressUpdate(
|
||||
super.task,
|
||||
this.progress, [
|
||||
this.expectedFileSize = -1,
|
||||
this.networkSpeed = -1,
|
||||
this.timeRemaining = const Duration(seconds: -1),
|
||||
]);
|
||||
|
||||
/// Create object from [json]
|
||||
TaskProgressUpdate.fromJson(super.json)
|
||||
: progress = (json['progress'] as num?)?.toDouble() ?? progressFailed,
|
||||
expectedFileSize = (json['expectedFileSize'] as num?)?.toInt() ?? -1,
|
||||
networkSpeed = (json['networkSpeed'] as num?)?.toDouble() ?? -1,
|
||||
timeRemaining = Duration(
|
||||
seconds: (json['timeRemaining'] as num?)?.toInt() ?? -1,
|
||||
),
|
||||
super.fromJson();
|
||||
|
||||
/// Create object from [jsonString]
|
||||
factory TaskProgressUpdate.fromJsonString(String jsonString) =>
|
||||
TaskProgressUpdate.fromJson(jsonDecode(jsonString));
|
||||
|
||||
/// Return JSON Map representing object
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {
|
||||
...super.toJson(),
|
||||
'progress': progress,
|
||||
'expectedFileSize': expectedFileSize,
|
||||
'networkSpeed': networkSpeed,
|
||||
'timeRemaining': timeRemaining.inSeconds,
|
||||
};
|
||||
|
||||
/// If true, [expectedFileSize] contains a valid value
|
||||
bool get hasExpectedFileSize => expectedFileSize >= 0;
|
||||
|
||||
/// If true, [networkSpeed] contains a valid value
|
||||
bool get hasNetworkSpeed => networkSpeed >= 0;
|
||||
|
||||
/// If true, [timeRemaining] contains a valid value
|
||||
bool get hasTimeRemaining => !timeRemaining.isNegative;
|
||||
|
||||
/// Use the [hasExpectedFileSize], [hasNetworkSpeed] and [hasTimeRemaining]
|
||||
/// getters to determine whether a field is valid
|
||||
///
|
||||
/// String is '-- MB/s' if N/A, otherwise in MB/s or kB/s
|
||||
String get networkSpeedAsString => switch (networkSpeed) {
|
||||
<= 0 => '-- MB/s',
|
||||
>= 1 => '${networkSpeed.round()} MB/s',
|
||||
_ => '${(networkSpeed * 1000).round()} kB/s',
|
||||
};
|
||||
|
||||
/// String is '--:--' if N/A, otherwise HH:MM:SS or MM:SS
|
||||
String get timeRemainingAsString => switch (timeRemaining.inSeconds) {
|
||||
<= 0 => '--:--',
|
||||
< 3600 =>
|
||||
'${timeRemaining.inMinutes.toString().padLeft(2, "0")}'
|
||||
':${timeRemaining.inSeconds.remainder(60).toString().padLeft(2, "0")}',
|
||||
_ =>
|
||||
'${timeRemaining.inHours}'
|
||||
':${timeRemaining.inMinutes.remainder(60).toString().padLeft(2, "0")}'
|
||||
':${timeRemaining.inSeconds.remainder(60).toString().padLeft(2, "0")}',
|
||||
};
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'TaskProgressUpdate{progress: $progress, expectedFileSize: $expectedFileSize, networkSpeed: $networkSpeed, timeRemaining: $timeRemaining}';
|
||||
}
|
||||
|
||||
// Progress values representing a status
|
||||
const progressRunning = 0.0;
|
||||
const progressComplete = 1.0;
|
||||
const progressFailed = -1.0;
|
||||
const progressCanceled = -2.0;
|
||||
const progressNotFound = -3.0;
|
||||
const progressWaitingToRetry = -4.0;
|
||||
const progressPaused = -5.0;
|
||||
|
||||
/// Holds data associated with a resume
|
||||
class ResumeData {
|
||||
final Task task;
|
||||
final String data;
|
||||
final int requiredStartByte;
|
||||
final String? eTag;
|
||||
|
||||
const ResumeData(
|
||||
this.task,
|
||||
this.data, [
|
||||
this.requiredStartByte = 0,
|
||||
this.eTag,
|
||||
]);
|
||||
|
||||
/// Create object from [json]
|
||||
ResumeData.fromJson(Map<String, dynamic> json)
|
||||
: task = Task.createFromJson(json['task']),
|
||||
data = json['data'] as String,
|
||||
requiredStartByte = (json['requiredStartByte'] as num?)?.toInt() ?? 0,
|
||||
eTag = json['eTag'] as String?;
|
||||
|
||||
/// Create object from [jsonString]
|
||||
factory ResumeData.fromJsonString(String jsonString) =>
|
||||
ResumeData.fromJson(jsonDecode(jsonString));
|
||||
|
||||
/// Return JSON Map representing object
|
||||
Map<String, dynamic> toJson() => {
|
||||
'task': task.toJson(),
|
||||
'data': data,
|
||||
'requiredStartByte': requiredStartByte,
|
||||
'eTag': eTag,
|
||||
};
|
||||
|
||||
String get taskId => task.taskId;
|
||||
|
||||
/// The tempFilepath contained in the [data] field
|
||||
String get tempFilepath => data;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ResumeData &&
|
||||
runtimeType == other.runtimeType &&
|
||||
task == other.task &&
|
||||
data == other.data &&
|
||||
requiredStartByte == other.requiredStartByte &&
|
||||
eTag == other.eTag;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
task.hashCode ^
|
||||
data.hashCode ^
|
||||
requiredStartByte.hashCode ^
|
||||
(eTag?.hashCode ?? 0);
|
||||
}
|
||||
|
||||
/// Types of undelivered data that can be requested
|
||||
enum Undelivered {
|
||||
/// Resume data
|
||||
resumeData,
|
||||
|
||||
/// Status updates
|
||||
statusUpdates,
|
||||
|
||||
/// Progress updates
|
||||
progressUpdates,
|
||||
}
|
||||
|
||||
/// Notification types, as configured in [TaskNotificationConfig] and passed
|
||||
/// on to [TaskNotificationTapCallback]
|
||||
enum NotificationType {
|
||||
/// Task is running
|
||||
running,
|
||||
|
||||
/// Task has completed
|
||||
complete,
|
||||
|
||||
/// Task has failed
|
||||
error,
|
||||
|
||||
/// Task is paused
|
||||
paused,
|
||||
|
||||
/// Task is canceled
|
||||
canceled,
|
||||
}
|
||||
|
||||
/// Notification specification for a [Task]
|
||||
///
|
||||
/// [body] and [title] may contain special strings to substitute display values:
|
||||
/// {filename] to insert the filename
|
||||
/// {progress} to insert progress in %
|
||||
/// {networkSpeed} to insert the network speed in MB/s or kB/s, or '--' if N/A
|
||||
/// {timeRemaining} to insert the estimated time remaining to complete the task
|
||||
/// in HH:MM:SS or MM:SS or --:-- if N/A
|
||||
///
|
||||
/// Actual appearance of notification is dependent on the platform, e.g.
|
||||
/// on iOS {progress} is not available and ignored
|
||||
final class const TaskNotification(final String title, final String body) {
|
||||
/// Return JSON Map representing object
|
||||
Map<String, dynamic> toJson() => {"title": title, "body": body};
|
||||
}
|
||||
|
||||
/// Notification configuration object
|
||||
///
|
||||
/// Determines how a [taskOrGroup] or [Task.group] of tasks needs to be notified
|
||||
///
|
||||
/// [running] is the notification used while the task is in progress
|
||||
/// [complete] is the notification used when the task completed
|
||||
/// [error] is the notification used when something went wrong,
|
||||
/// including pause, failed and notFound status
|
||||
/// [progressBar] if set will show a progress bar
|
||||
/// [tapOpensFile] if set will attempt to open the file when the [complete]
|
||||
/// notification is tapped
|
||||
/// [groupNotificationId] if set will group all notifications with the same
|
||||
/// [groupNotificationId] and change the progress bar to number of finished
|
||||
/// tasks versus total number of tasks in the groupNotification.
|
||||
/// Use {finished} and {total} tokens in the [TaskNotification.title] and
|
||||
/// [TaskNotification.body] to substitute. Task-specific substitutions
|
||||
/// such as {filename} are not valid.
|
||||
/// The groupNotification is considered [complete] when there are no
|
||||
/// more tasks running within that group, and at that point the
|
||||
/// [complete] notification is shown (if configured). If any task in the
|
||||
/// groupNotification fails, the [error] notification is shown.
|
||||
/// The first character of the [groupNotificationId] cannot be '*'.
|
||||
final class TaskNotificationConfig {
|
||||
final dynamic taskOrGroup;
|
||||
final TaskNotification? running;
|
||||
final TaskNotification? complete;
|
||||
final TaskNotification? error;
|
||||
final TaskNotification? paused;
|
||||
final TaskNotification? canceled;
|
||||
final bool progressBar;
|
||||
final bool tapOpensFile;
|
||||
final String groupNotificationId;
|
||||
|
||||
/// Create notification configuration that determines what notifications are shown,
|
||||
/// whether a progress bar is shown (Android only), and whether tapping
|
||||
/// the 'complete' notification opens the downloaded file.
|
||||
///
|
||||
/// [running] is the notification used while the task is in progress
|
||||
/// [complete] is the notification used when the task completed
|
||||
/// [error] is the notification used when something went wrong,
|
||||
/// including failed and notFound status
|
||||
/// [paused] is the notification shown when the task is paused
|
||||
/// [canceled] is the notification shown when the task is canceled programmatically
|
||||
/// or by the user via a notification button
|
||||
/// [progressBar] if set will show a progress bar
|
||||
/// [tapOpensFile] if set will attempt to open the file when the [complete]
|
||||
/// notification is tapped
|
||||
/// [groupNotificationId] if set will group all notifications with the same
|
||||
/// [groupNotificationId] and change the progress bar to number of finished
|
||||
/// tasks versus total number of tasks in the groupNotification.
|
||||
/// Use {numFinished}, {numFailed} and {numTotal} tokens in the [TaskNotification.title]
|
||||
/// and [TaskNotification.body] to substitute. Task-specific substitutions
|
||||
/// such as {filename} are not valid.
|
||||
/// The groupNotification is considered [complete] when there are no
|
||||
/// more tasks running within that group, and at that point the
|
||||
/// [complete] notification is shown (if configured). If any task in the
|
||||
/// groupNotification fails, the [error] notification is shown.
|
||||
/// The first character of the [groupNotificationId] cannot be '*'.
|
||||
TaskNotificationConfig({
|
||||
this.taskOrGroup,
|
||||
this.running,
|
||||
this.complete,
|
||||
this.error,
|
||||
this.paused,
|
||||
this.canceled,
|
||||
this.progressBar = false,
|
||||
this.tapOpensFile = false,
|
||||
this.groupNotificationId = '',
|
||||
}) {
|
||||
assert(
|
||||
running != null ||
|
||||
complete != null ||
|
||||
error != null ||
|
||||
paused != null ||
|
||||
canceled != null,
|
||||
'At least one notification must be set',
|
||||
);
|
||||
}
|
||||
|
||||
/// Return JSON Map representing object, excluding the [taskOrGroup] field,
|
||||
/// as the JSON map is only required to pass along the config with a task
|
||||
Map<String, dynamic> toJson() => {
|
||||
'running': running?.toJson(),
|
||||
'complete': complete?.toJson(),
|
||||
'error': error?.toJson(),
|
||||
'paused': paused?.toJson(),
|
||||
'canceled': canceled?.toJson(),
|
||||
'progressBar': progressBar,
|
||||
'tapOpensFile': tapOpensFile,
|
||||
'groupNotificationId': groupNotificationId,
|
||||
};
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is TaskNotificationConfig &&
|
||||
runtimeType == other.runtimeType &&
|
||||
taskOrGroup == other.taskOrGroup;
|
||||
|
||||
@override
|
||||
int get hashCode => taskOrGroup.hashCode;
|
||||
}
|
||||
|
||||
/// Shared storage destinations
|
||||
enum SharedStorage {
|
||||
/// The 'Downloads' directory
|
||||
downloads,
|
||||
|
||||
/// The 'Photos' or 'Images' or 'Pictures' directory
|
||||
images,
|
||||
|
||||
/// The 'Videos' or 'Movies' directory
|
||||
video,
|
||||
|
||||
/// The 'Music' or 'Audio' directory
|
||||
audio,
|
||||
|
||||
/// Android-only: the 'Files' directory
|
||||
files,
|
||||
|
||||
/// Android-only: the 'external storage' directory
|
||||
external,
|
||||
}
|
||||
|
||||
final class Config {
|
||||
// Config topics
|
||||
|
||||
/// Config string for request timeout
|
||||
static const requestTimeout = 'requestTimeout';
|
||||
|
||||
/// Config string for resource timeout
|
||||
static const resourceTimeout = 'resourceTimeout';
|
||||
|
||||
/// Config string for check available space
|
||||
static const checkAvailableSpace = 'checkAvailableSpace';
|
||||
|
||||
/// Config string for proxy
|
||||
static const proxy = 'proxy';
|
||||
|
||||
/// Config string for bypass TLS certificate validation
|
||||
static const bypassTLSCertificateValidation =
|
||||
'bypassTLSCertificateValidation';
|
||||
|
||||
/// Config string for mutual TLS (mTLS)
|
||||
static const mTLS = 'mTLS';
|
||||
|
||||
/// Config string for run in foreground
|
||||
static const runInForeground = 'runInForeground';
|
||||
|
||||
/// Config string for run in foreground if file larger than
|
||||
static const runInForegroundIfFileLargerThan =
|
||||
'runInForegroundIfFileLargerThan';
|
||||
|
||||
/// Config string for localize
|
||||
static const localize = 'localize';
|
||||
|
||||
/// Config string for exclude from cloud backup
|
||||
static const excludeFromCloudBackup = 'excludeFromCloudBackup';
|
||||
|
||||
/// Config string for use cache dir
|
||||
static const useCacheDir = 'useCacheDir';
|
||||
|
||||
/// Config string for use external storage
|
||||
static const useExternalStorage = 'useExternalStorage';
|
||||
|
||||
/// Config string for holding queue
|
||||
static const holdingQueue = 'holdingQueue';
|
||||
|
||||
/// Config string for skip existing files
|
||||
static const skipExistingFiles = 'skipExistingFiles';
|
||||
|
||||
/// Config string for temp file path
|
||||
static const tempFilePath = 'tempFilePath';
|
||||
|
||||
// Config arguments
|
||||
|
||||
/// Config argument for always
|
||||
static const always = 'always'; // int 0 on native side
|
||||
|
||||
/// Config argument for never
|
||||
static const never = 'never'; // int -1 on native side
|
||||
|
||||
/// Config argument for when able
|
||||
static const whenAble = 'whenAble'; // int -2 on native side
|
||||
|
||||
/// Returns the int equivalent of commonly used String arguments
|
||||
///
|
||||
/// The int equivalent is used in communication with the native downloader
|
||||
static int argToInt(String argument) => switch (argument) {
|
||||
Config.always => 0,
|
||||
Config.whenAble => -2,
|
||||
Config.never => -1,
|
||||
_ => throw ArgumentError('Argument $argument cannot be converted to int'),
|
||||
};
|
||||
}
|
||||
|
||||
/// Wifi requirement modes at the application level
|
||||
enum RequireWiFi { asSetByTask, forAllTasks, forNoTasks }
|
||||
@@ -0,0 +1,886 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'base_downloader.dart';
|
||||
import 'chunk.dart';
|
||||
import 'exceptions.dart';
|
||||
import 'file_downloader.dart';
|
||||
import 'models.dart';
|
||||
import 'json_processor.dart';
|
||||
import 'permissions.dart';
|
||||
import 'queue/serial_job_queue.dart';
|
||||
import 'task.dart';
|
||||
|
||||
/// Implementation of download functionality for native platforms
|
||||
///
|
||||
/// Uses [MethodChannel] to communicate with native platforms
|
||||
abstract base class NativeDownloader extends BaseDownloader {
|
||||
static const methodChannel = MethodChannel(
|
||||
'com.bbflight.background_downloader',
|
||||
);
|
||||
static const _backgroundChannel = MethodChannel(
|
||||
'com.bbflight.background_downloader.background',
|
||||
);
|
||||
|
||||
late final SerialJobQueue<MethodCall, dynamic> _jobQueue;
|
||||
|
||||
/// Initializes the background channel and starts listening for messages from
|
||||
/// the native side
|
||||
@override
|
||||
Future<void> initialize() async {
|
||||
await super.initialize();
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
// listen to the background channel, receiving updates on download status
|
||||
// or progress.
|
||||
// The job queue ensures that messages are processed in order, even though
|
||||
// the processing itself is asynchronous (using [compute])
|
||||
_jobQueue = SerialJobQueue(_handleBackgroundMessage);
|
||||
_backgroundChannel.setMethodCallHandler(_jobQueue.add);
|
||||
}
|
||||
|
||||
/// Handles the background message
|
||||
///
|
||||
/// First argument is the Task as JSON string, next argument(s) depends
|
||||
/// on the method.
|
||||
///
|
||||
/// If the task JsonString is empty, a dummy task will be created
|
||||
Future<dynamic> _handleBackgroundMessage(MethodCall call) async {
|
||||
final args = call.arguments as List<dynamic>;
|
||||
final taskJsonString = args.first as String;
|
||||
final task = taskJsonString.isNotEmpty
|
||||
? await JsonProcessor().decodeTask(taskJsonString)
|
||||
: DownloadTask(url: 'url');
|
||||
final message = (
|
||||
call.method,
|
||||
args.length > 2
|
||||
? args.getRange(1, args.length).toList(growable: false)
|
||||
: args[1],
|
||||
);
|
||||
switch (message) {
|
||||
// simple status update
|
||||
case ('statusUpdate', final int statusOrdinal):
|
||||
final status = TaskStatus.values[statusOrdinal];
|
||||
if (task.group != BaseDownloader.chunkGroup) {
|
||||
processStatusUpdate(TaskStatusUpdate(task, status));
|
||||
} else {
|
||||
// this is a chunk task, so pass to native
|
||||
Future.delayed(const Duration(milliseconds: 100)).then(
|
||||
(_) => methodChannel.invokeMethod('chunkStatusUpdate', [
|
||||
Chunk.getParentTaskId(task),
|
||||
task.taskId,
|
||||
status.index,
|
||||
null,
|
||||
null,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
// status update with responseBody, responseHeaders, responseStatusCode, mimeType and charSet (normal completion)
|
||||
case (
|
||||
'statusUpdate',
|
||||
[
|
||||
final int statusOrdinal,
|
||||
final String? responseBody,
|
||||
final Map<Object?, Object?>? responseHeaders,
|
||||
final int? responseStatusCode,
|
||||
final String? mimeType,
|
||||
final String? charSet,
|
||||
],
|
||||
):
|
||||
final status = TaskStatus.values[statusOrdinal];
|
||||
if (task.group != BaseDownloader.chunkGroup) {
|
||||
final Map<String, String>? cleanResponseHeaders =
|
||||
responseHeaders == null
|
||||
? null
|
||||
: {
|
||||
for (final entry in responseHeaders.entries.where(
|
||||
(entry) => entry.key != null && entry.value != null,
|
||||
))
|
||||
entry.key.toString().toLowerCase(): entry.value.toString(),
|
||||
};
|
||||
processStatusUpdate(
|
||||
TaskStatusUpdate(
|
||||
task,
|
||||
status,
|
||||
null,
|
||||
responseBody,
|
||||
cleanResponseHeaders,
|
||||
responseStatusCode,
|
||||
mimeType,
|
||||
charSet,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// this is a chunk task, so pass to native
|
||||
Future.delayed(const Duration(milliseconds: 100)).then(
|
||||
(_) => methodChannel.invokeMethod('chunkStatusUpdate', [
|
||||
Chunk.getParentTaskId(task),
|
||||
task.taskId,
|
||||
status.index,
|
||||
null,
|
||||
responseBody,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
// status update with TaskException and responseBody
|
||||
case (
|
||||
'statusUpdate',
|
||||
[
|
||||
final int statusOrdinal,
|
||||
final String typeString,
|
||||
final String description,
|
||||
final int httpResponseCode,
|
||||
final String? responseBody,
|
||||
],
|
||||
):
|
||||
final status = TaskStatus.values[statusOrdinal];
|
||||
TaskException? exception;
|
||||
if (status == TaskStatus.failed) {
|
||||
exception = TaskException.fromTypeString(
|
||||
typeString,
|
||||
description,
|
||||
httpResponseCode,
|
||||
);
|
||||
}
|
||||
if (task.group != BaseDownloader.chunkGroup) {
|
||||
processStatusUpdate(
|
||||
TaskStatusUpdate(task, status, exception, responseBody),
|
||||
);
|
||||
} else {
|
||||
// this is a chunk task, so pass to native
|
||||
Future.delayed(const Duration(milliseconds: 100)).then(
|
||||
(_) => methodChannel.invokeMethod('chunkStatusUpdate', [
|
||||
Chunk.getParentTaskId(task),
|
||||
task.taskId,
|
||||
status.index,
|
||||
exception?.toJsonString(),
|
||||
responseBody,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
case (
|
||||
'progressUpdate',
|
||||
[
|
||||
final double progress,
|
||||
final int expectedFileSize,
|
||||
final double networkSpeed,
|
||||
final int timeRemaining,
|
||||
],
|
||||
):
|
||||
if (task.group != BaseDownloader.chunkGroup) {
|
||||
processProgressUpdate(
|
||||
TaskProgressUpdate(
|
||||
task,
|
||||
progress,
|
||||
expectedFileSize,
|
||||
networkSpeed,
|
||||
Duration(milliseconds: timeRemaining),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// this is a chunk task, so pass parent taskId,
|
||||
// chunk taskId and progress to native
|
||||
Future.delayed(const Duration(milliseconds: 100)).then(
|
||||
(_) => methodChannel.invokeMethod('chunkProgressUpdate', [
|
||||
Chunk.getParentTaskId(task),
|
||||
task.taskId,
|
||||
progress,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
case ('canResume', final bool canResume):
|
||||
setCanResume(task, canResume);
|
||||
|
||||
// resumeData Android and Desktop variant
|
||||
case (
|
||||
'resumeData',
|
||||
[final String data, final int requiredStartByte, final String? eTag],
|
||||
):
|
||||
setResumeData(ResumeData(task, data, requiredStartByte, eTag));
|
||||
|
||||
// resumeData iOS and ParallelDownloads variant
|
||||
case ('resumeData', final String data):
|
||||
setResumeData(ResumeData(task, data));
|
||||
|
||||
case ('notificationTap', final int notificationTypeOrdinal):
|
||||
final notificationType =
|
||||
NotificationType.values[notificationTypeOrdinal];
|
||||
processNotificationTap(task, notificationType);
|
||||
return true; // this message requires a confirmation
|
||||
|
||||
// from ParallelDownloadTask
|
||||
case ('enqueueChild', final String childTaskJsonString):
|
||||
final childTask = await JsonProcessor().decodeTask(childTaskJsonString);
|
||||
// The parent treats this acknowledgement as proof that a child exists.
|
||||
// Returning before native enqueue finishes strands the parent forever
|
||||
// when WorkManager rejects that child. Return the real result instead.
|
||||
try {
|
||||
return await FileDownloader().enqueue(childTask);
|
||||
} catch (error) {
|
||||
log.warning('Native child enqueue failed', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
// from ParallelDownloadTask
|
||||
case ('cancelTasksWithId', final String listOfTaskIdsJson):
|
||||
final taskIds = List<String>.from(jsonDecode(listOfTaskIdsJson));
|
||||
Future.delayed(const Duration(milliseconds: 100))
|
||||
.then((_) => FileDownloader().cancelTasksWithIds(taskIds));
|
||||
|
||||
// from ParallelDownloadTask
|
||||
case ('pauseTasks', final String listOfTasksJson):
|
||||
final listOfTasks = await JsonProcessor().decodeDownloadTaskList(
|
||||
listOfTasksJson,
|
||||
);
|
||||
Future.delayed(const Duration(milliseconds: 100)).then((_) async {
|
||||
for (final chunkTask in listOfTasks) {
|
||||
await FileDownloader().pause(chunkTask);
|
||||
}
|
||||
});
|
||||
|
||||
// for permission request results
|
||||
case ('permissionRequestResult', final int statusOrdinal):
|
||||
permissionsService.onPermissionRequestResult(
|
||||
PermissionStatus.values[statusOrdinal],
|
||||
);
|
||||
|
||||
default:
|
||||
log.warning('Background channel: no match for message $message');
|
||||
throw ArgumentError(
|
||||
'Background channel: no match for message $message',
|
||||
);
|
||||
}
|
||||
await flushDatabaseUpdates();
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> enqueue(Task task) async {
|
||||
super.enqueue(task);
|
||||
final notificationConfig = notificationConfigForTask(task);
|
||||
return await methodChannel.invokeMethod<bool>('enqueue', [
|
||||
jsonEncode(task.toJson()),
|
||||
if (notificationConfig != null)
|
||||
jsonEncode(notificationConfig.toJson())
|
||||
else
|
||||
null,
|
||||
]) ??
|
||||
false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<bool>> enqueueAll(Iterable<Task> tasks) async {
|
||||
for (final task in tasks.where((task) => task.allowPause)) {
|
||||
canResumeTask[task] = Completer();
|
||||
}
|
||||
tasks.forEach(registerTaskNotificationConfig);
|
||||
final (
|
||||
String tasksJsonString,
|
||||
String notificationConfigsJsonString,
|
||||
) = await JsonProcessor().encodeTaskAndNotificationConfig(
|
||||
tasks,
|
||||
notificationConfigs,
|
||||
);
|
||||
final result =
|
||||
await methodChannel.invokeMethod<List<Object?>>('enqueueAll', [
|
||||
tasksJsonString,
|
||||
notificationConfigsJsonString,
|
||||
]) ??
|
||||
[];
|
||||
return result.map((item) => item is bool ? item : false).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> reset(String group) async {
|
||||
final retryAndPausedTaskCount = await super.reset(group);
|
||||
final nativeCount =
|
||||
await methodChannel.invokeMethod<int>('reset', group) ?? 0;
|
||||
return retryAndPausedTaskCount + nativeCount;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Task>> allTasks(
|
||||
String group,
|
||||
bool includeTasksWaitingToRetry,
|
||||
allGroups,
|
||||
) async {
|
||||
final retryAndPausedTasks = await super.allTasks(
|
||||
group,
|
||||
includeTasksWaitingToRetry,
|
||||
allGroups,
|
||||
);
|
||||
final result =
|
||||
await methodChannel.invokeMethod<List<dynamic>?>(
|
||||
'allTasks',
|
||||
allGroups ? null : group,
|
||||
) ??
|
||||
[];
|
||||
final tasks = await JsonProcessor().decodeTaskList(result);
|
||||
return [...retryAndPausedTasks, ...tasks];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> cancelPlatformTasksWithIds(List<String> taskIds) async =>
|
||||
await methodChannel.invokeMethod<bool>('cancelTasksWithIds', taskIds) ??
|
||||
false;
|
||||
|
||||
@override
|
||||
Future<Task?> taskForId(String taskId) async {
|
||||
final task = await super.taskForId(taskId);
|
||||
if (task != null) {
|
||||
return task;
|
||||
}
|
||||
final jsonString = await methodChannel.invokeMethod<String>(
|
||||
'taskForId',
|
||||
taskId,
|
||||
);
|
||||
if (jsonString != null) {
|
||||
return Task.createFromJsonString(jsonString);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> pause(Task task) async =>
|
||||
await methodChannel.invokeMethod<bool>('pause', task.taskId) ?? false;
|
||||
|
||||
@override
|
||||
Future<List<bool>> pauseTaskList(Iterable<Task> tasksToPause) async {
|
||||
final taskIds = tasksToPause
|
||||
.map((task) => task.taskId)
|
||||
.toList(growable: false);
|
||||
final results = await methodChannel.invokeMethod<List<Object?>>(
|
||||
'pauseAll',
|
||||
taskIds,
|
||||
);
|
||||
return results?.cast<bool>() ?? tasksToPause.map((task) => false).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> resume(Task task) async {
|
||||
if (await super.resume(task)) {
|
||||
task = awaitTasks.containsKey(task)
|
||||
? awaitTasks.keys.firstWhere(
|
||||
(awaitTask) => awaitTask.taskId == task.taskId,
|
||||
)
|
||||
: task;
|
||||
final taskResumeData = await getResumeData(task.taskId);
|
||||
if (taskResumeData != null) {
|
||||
final notificationConfig = notificationConfigForTask(task);
|
||||
final enqueueSuccess =
|
||||
await methodChannel.invokeMethod<bool>('enqueue', [
|
||||
jsonEncode(task.toJson()),
|
||||
if (notificationConfig != null)
|
||||
jsonEncode(notificationConfig.toJson())
|
||||
else
|
||||
null,
|
||||
taskResumeData.data,
|
||||
taskResumeData.requiredStartByte,
|
||||
taskResumeData.eTag,
|
||||
]) ??
|
||||
false;
|
||||
if (enqueueSuccess && task is ParallelDownloadTask) {
|
||||
return resumeChunkTasks(task, taskResumeData);
|
||||
}
|
||||
return enqueueSuccess;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> requireWiFi(
|
||||
RequireWiFi requirement,
|
||||
rescheduleRunningTasks,
|
||||
alsoRestartUploads,
|
||||
) async =>
|
||||
await methodChannel.invokeMethod('requireWiFi', [
|
||||
requirement.index,
|
||||
rescheduleRunningTasks,
|
||||
alsoRestartUploads,
|
||||
]) ??
|
||||
false;
|
||||
|
||||
@override
|
||||
Future<RequireWiFi> getRequireWiFiSetting() async => RequireWiFi
|
||||
.values[await methodChannel.invokeMethod('getRequireWiFiSetting') ?? 0];
|
||||
|
||||
@override
|
||||
void updateNotification(Task task, TaskStatus? taskStatusOrNull) {
|
||||
final notificationConfig = notificationConfigForTask(task);
|
||||
if (notificationConfig != null) {
|
||||
methodChannel.invokeMethod('updateNotification', [
|
||||
jsonEncode(task.toJson()),
|
||||
jsonEncode(notificationConfig.toJson()),
|
||||
taskStatusOrNull?.index,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve data that was not delivered to Dart, as a Map keyed by taskId
|
||||
/// with one map for each taskId
|
||||
///
|
||||
/// Asks the native platform for locally stored data for resumeData,
|
||||
/// status updates or progress updates.
|
||||
/// ResumeData has a [ResumeData] json representation
|
||||
/// StatusUpdates has a mixed Task & TaskStatus json representation 'taskStatus'
|
||||
/// ProgressUpdates has a mixed Task & double json representation 'progress'
|
||||
@override
|
||||
Future<Map<String, String>> popUndeliveredData(Undelivered dataType) async {
|
||||
final String jsonString = await switch (dataType) {
|
||||
Undelivered.resumeData => methodChannel.invokeMethod('popResumeData'),
|
||||
Undelivered.statusUpdates => methodChannel.invokeMethod(
|
||||
'popStatusUpdates',
|
||||
),
|
||||
Undelivered.progressUpdates => methodChannel.invokeMethod(
|
||||
'popProgressUpdates',
|
||||
),
|
||||
};
|
||||
return Map.from(jsonDecode(jsonString));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> moveToSharedStorage(
|
||||
String filePath,
|
||||
SharedStorage destination,
|
||||
String directory,
|
||||
String? mimeType, {
|
||||
bool asUriString = false,
|
||||
}) => methodChannel.invokeMethod<String?>('moveToSharedStorage', [
|
||||
filePath,
|
||||
destination.index,
|
||||
directory,
|
||||
mimeType,
|
||||
asUriString,
|
||||
]);
|
||||
|
||||
@override
|
||||
Future<String?> pathInSharedStorage(
|
||||
String filePath,
|
||||
SharedStorage destination,
|
||||
String directory, {
|
||||
bool asUriString = false,
|
||||
}) => methodChannel.invokeMethod<String?>('pathInSharedStorage', [
|
||||
filePath,
|
||||
destination.index,
|
||||
directory,
|
||||
asUriString,
|
||||
]);
|
||||
|
||||
@override
|
||||
Future<bool> openFile(Task? task, String? filePath, String? mimeType) async {
|
||||
final result = await methodChannel.invokeMethod<bool>('openFile', [
|
||||
if (task != null) jsonEncode(task.toJson()) else null,
|
||||
filePath,
|
||||
mimeType,
|
||||
]);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> platformVersion() async =>
|
||||
(await methodChannel.invokeMethod<String>('platformVersion')) ?? '';
|
||||
|
||||
@override
|
||||
Future<Duration> getTaskTimeout() async {
|
||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||
final timeoutMillis =
|
||||
await methodChannel.invokeMethod<int>('getTaskTimeout') ?? 0;
|
||||
return Duration(milliseconds: timeoutMillis);
|
||||
}
|
||||
return const Duration(hours: 4); // on iOS, resource timeout
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setForceFailPostOnBackgroundChannel(bool value) async {
|
||||
await methodChannel.invokeMethod('forceFailPostOnBackgroundChannel', value);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> testSuggestedFilename(
|
||||
DownloadTask task,
|
||||
String contentDisposition,
|
||||
) async =>
|
||||
await methodChannel.invokeMethod<String>('testSuggestedFilename', [
|
||||
jsonEncode(task.toJson()),
|
||||
contentDisposition,
|
||||
]) ??
|
||||
'';
|
||||
|
||||
@override
|
||||
Future<(String, String)> configureItem((String, dynamic) configItem) async {
|
||||
switch (configItem) {
|
||||
case (Config.requestTimeout, final Duration? duration):
|
||||
await NativeDownloader.methodChannel.invokeMethod(
|
||||
'configRequestTimeout',
|
||||
duration?.inSeconds,
|
||||
);
|
||||
|
||||
case (Config.proxy, (final String address, final int port)):
|
||||
await NativeDownloader.methodChannel.invokeMethod(
|
||||
'configProxyAddress',
|
||||
address,
|
||||
);
|
||||
await NativeDownloader.methodChannel.invokeMethod(
|
||||
'configProxyPort',
|
||||
port,
|
||||
);
|
||||
|
||||
case (Config.proxy, false):
|
||||
await NativeDownloader.methodChannel.invokeMethod(
|
||||
'configProxyAddress',
|
||||
null,
|
||||
);
|
||||
await NativeDownloader.methodChannel.invokeMethod(
|
||||
'configProxyPort',
|
||||
null,
|
||||
);
|
||||
|
||||
case (Config.checkAvailableSpace, final int minimum):
|
||||
assert(minimum > 0, 'Minimum available space must be in MB and > 0');
|
||||
await NativeDownloader.methodChannel.invokeMethod(
|
||||
'configCheckAvailableSpace',
|
||||
minimum,
|
||||
);
|
||||
|
||||
case (Config.checkAvailableSpace, false):
|
||||
case (Config.checkAvailableSpace, Config.never):
|
||||
await NativeDownloader.methodChannel.invokeMethod(
|
||||
'configCheckAvailableSpace',
|
||||
null,
|
||||
);
|
||||
|
||||
case (
|
||||
Config.holdingQueue,
|
||||
(
|
||||
final int? maxConcurrent,
|
||||
final int? maxConcurrentByHost,
|
||||
final int? maxConcurrentByGroup,
|
||||
),
|
||||
):
|
||||
await NativeDownloader.methodChannel.invokeMethod(
|
||||
'configHoldingQueue',
|
||||
[
|
||||
maxConcurrent ?? 1 << 20,
|
||||
maxConcurrentByHost ?? 1 << 20,
|
||||
maxConcurrentByGroup ?? 1 << 20,
|
||||
],
|
||||
);
|
||||
|
||||
case (Config.holdingQueue, Config.never):
|
||||
case (Config.holdingQueue, false):
|
||||
await NativeDownloader.methodChannel.invokeMethod(
|
||||
'configHoldingQueue',
|
||||
[],
|
||||
);
|
||||
|
||||
case (Config.skipExistingFiles, final int value):
|
||||
await NativeDownloader.methodChannel.invokeMethod(
|
||||
'configSkipExistingFiles',
|
||||
value,
|
||||
);
|
||||
|
||||
case (Config.skipExistingFiles, Config.never):
|
||||
case (Config.skipExistingFiles, false):
|
||||
await NativeDownloader.methodChannel.invokeMethod(
|
||||
'configSkipExistingFiles',
|
||||
-1,
|
||||
);
|
||||
|
||||
case (Config.skipExistingFiles, Config.always):
|
||||
case (Config.skipExistingFiles, true):
|
||||
await NativeDownloader.methodChannel.invokeMethod(
|
||||
'configSkipExistingFiles',
|
||||
0,
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
configItem.$1,
|
||||
'not implemented',
|
||||
); // this method did not process this configItem
|
||||
}
|
||||
return (configItem.$1, ''); // normal result
|
||||
}
|
||||
}
|
||||
|
||||
/// Android native downloader
|
||||
final class AndroidDownloader extends NativeDownloader {
|
||||
static final AndroidDownloader _singleton = AndroidDownloader._internal();
|
||||
static int? _callbackDispatcherRawHandle;
|
||||
|
||||
factory AndroidDownloader() => _singleton;
|
||||
|
||||
AndroidDownloader._internal();
|
||||
|
||||
@override
|
||||
Future<bool> enqueue(Task task) async {
|
||||
await _registerCallbackDispatcher(task);
|
||||
return super.enqueue(task);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<bool>> enqueueAll(Iterable<Task> tasks) async {
|
||||
for (final task in tasks) {
|
||||
await _registerCallbackDispatcher(task);
|
||||
}
|
||||
return super.enqueueAll(tasks);
|
||||
}
|
||||
|
||||
/// On Android, need to register [_callbackDispatcherRawHandle] upon first
|
||||
/// encounter of a task with callbacks
|
||||
Future<void> _registerCallbackDispatcher(Task task) async {
|
||||
// on Android, need to register [_callbackDispatcherRawHandle] upon first
|
||||
// encounter of a task with callbacks
|
||||
if (task.options?.hasCallback == true &&
|
||||
_callbackDispatcherRawHandle == null) {
|
||||
final rawHandle = PluginUtilities.getCallbackHandle(
|
||||
initCallbackDispatcher,
|
||||
)?.toRawHandle();
|
||||
if (rawHandle != null) {
|
||||
final success = await NativeDownloader.methodChannel.invokeMethod<bool>(
|
||||
'registerCallbackDispatcher',
|
||||
rawHandle,
|
||||
);
|
||||
if (success == true) {
|
||||
_callbackDispatcherRawHandle = rawHandle;
|
||||
log.fine('Registered callbackDispatcher with handle $rawHandle');
|
||||
} else {
|
||||
log.warning('Could not register callbackDispatcher');
|
||||
}
|
||||
} else {
|
||||
log.warning('Could not obtain rawHandle for initCallbackDispatcher');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic platformConfig({
|
||||
dynamic globalConfig,
|
||||
dynamic androidConfig,
|
||||
dynamic iOSConfig,
|
||||
dynamic desktopConfig,
|
||||
}) => androidConfig;
|
||||
|
||||
@override
|
||||
Future<(String, String)> configureItem((String, dynamic) configItem) async {
|
||||
final superResult = await super.configureItem(configItem);
|
||||
if (superResult.$2 != 'not implemented') {
|
||||
return superResult;
|
||||
}
|
||||
switch (configItem) {
|
||||
case (Config.runInForeground, final bool activate):
|
||||
await NativeDownloader.methodChannel.invokeMethod(
|
||||
'configForegroundFileSize',
|
||||
activate ? 0 : -1,
|
||||
);
|
||||
|
||||
case (Config.runInForeground, final String whenTo):
|
||||
assert(
|
||||
[Config.never, Config.always].contains(whenTo),
|
||||
'${Config.runInForeground} expects one of ${[Config.never, Config.always]}',
|
||||
);
|
||||
await NativeDownloader.methodChannel.invokeMethod(
|
||||
'configForegroundFileSize',
|
||||
Config.argToInt(whenTo),
|
||||
);
|
||||
|
||||
case (Config.runInForegroundIfFileLargerThan, final int fileSize):
|
||||
await NativeDownloader.methodChannel.invokeMethod(
|
||||
'configForegroundFileSize',
|
||||
fileSize,
|
||||
);
|
||||
|
||||
case (Config.bypassTLSCertificateValidation, final bool bypass):
|
||||
if (bypass) {
|
||||
if (kReleaseMode) {
|
||||
throw ArgumentError(
|
||||
'You cannot bypass certificate validation in release mode',
|
||||
);
|
||||
}
|
||||
await NativeDownloader.methodChannel.invokeMethod(
|
||||
'configBypassTLSCertificateValidation',
|
||||
);
|
||||
log.warning(
|
||||
'TLS certificate validation is bypassed. This is insecure and cannot be '
|
||||
'done in release mode',
|
||||
);
|
||||
} else {
|
||||
throw ArgumentError(
|
||||
'To undo bypassing the certificate validation, '
|
||||
'restart and leave out the "configBypassCertificateValidation" configuration',
|
||||
);
|
||||
}
|
||||
|
||||
case (Config.useCacheDir, final String whenTo):
|
||||
assert(
|
||||
[Config.never, Config.whenAble, Config.always].contains(whenTo),
|
||||
'${Config.useCacheDir} expects one of ${[Config.never, Config.whenAble, Config.always]}',
|
||||
);
|
||||
await NativeDownloader.methodChannel.invokeMethod(
|
||||
'configUseCacheDir',
|
||||
Config.argToInt(whenTo),
|
||||
);
|
||||
|
||||
case (Config.useExternalStorage, final String whenTo):
|
||||
assert(
|
||||
[Config.never, Config.always].contains(whenTo),
|
||||
'${Config.useExternalStorage} expects one of ${[Config.never, Config.always]}',
|
||||
);
|
||||
await NativeDownloader.methodChannel.invokeMethod(
|
||||
'configUseExternalStorage',
|
||||
Config.argToInt(whenTo),
|
||||
);
|
||||
Task.useExternalStorage = whenTo == Config.always;
|
||||
|
||||
case (Config.tempFilePath, final String path):
|
||||
final cleanPath = (path.isNotEmpty && path != Config.never)
|
||||
? path
|
||||
: null;
|
||||
await NativeDownloader.methodChannel.invokeMethod(
|
||||
'configTempFilePath',
|
||||
cleanPath,
|
||||
);
|
||||
|
||||
case (Config.tempFilePath, null):
|
||||
case (Config.tempFilePath, false):
|
||||
await NativeDownloader.methodChannel.invokeMethod(
|
||||
'configTempFilePath',
|
||||
null,
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
configItem.$1,
|
||||
'not implemented',
|
||||
); // this method did not process this configItem
|
||||
}
|
||||
return (configItem.$1, ''); // normal result
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS native downloader
|
||||
final class IOSDownloader extends NativeDownloader {
|
||||
static final IOSDownloader _singleton = IOSDownloader._internal();
|
||||
|
||||
factory IOSDownloader() => _singleton;
|
||||
|
||||
IOSDownloader._internal();
|
||||
|
||||
/// On iOS we immediately initialize the callback dispatcher and start
|
||||
/// listening for callback messages from the native side
|
||||
///
|
||||
/// Whereas on Android, the initialization is done directly by the native side
|
||||
/// using a DartExecutor
|
||||
@override
|
||||
Future<void> initialize() async {
|
||||
initCallbackDispatcher();
|
||||
return super.initialize();
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic platformConfig({
|
||||
dynamic globalConfig,
|
||||
dynamic androidConfig,
|
||||
dynamic iOSConfig,
|
||||
dynamic desktopConfig,
|
||||
}) => iOSConfig;
|
||||
|
||||
@override
|
||||
Future<(String, String)> configureItem((String, dynamic) configItem) async {
|
||||
final superResult = await super.configureItem(configItem);
|
||||
if (superResult.$2 != 'not implemented') {
|
||||
return superResult;
|
||||
}
|
||||
switch (configItem) {
|
||||
case (Config.resourceTimeout, final Duration? duration):
|
||||
await NativeDownloader.methodChannel.invokeMethod(
|
||||
'configResourceTimeout',
|
||||
duration?.inSeconds,
|
||||
);
|
||||
|
||||
case (Config.localize, final Map<String, String>? translation):
|
||||
await NativeDownloader.methodChannel.invokeMethod(
|
||||
'configLocalize',
|
||||
translation,
|
||||
);
|
||||
|
||||
case (Config.excludeFromCloudBackup, final dynamic exclude):
|
||||
assert(
|
||||
exclude is bool || [Config.always, Config.never].contains(exclude),
|
||||
'${Config.excludeFromCloudBackup} expects one of ${['true', 'false', Config.never, Config.always]}',
|
||||
);
|
||||
final boolValue = (exclude == true || exclude == Config.always);
|
||||
await NativeDownloader.methodChannel.invokeMethod(
|
||||
'configExcludeFromCloudBackup',
|
||||
boolValue,
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
configItem.$1,
|
||||
'not implemented',
|
||||
); // this method did not process this configItem
|
||||
}
|
||||
return (configItem.$1, ''); // normal result
|
||||
}
|
||||
}
|
||||
|
||||
const _callbackChannel = MethodChannel(
|
||||
'com.bbflight.background_downloader.callbacks',
|
||||
);
|
||||
|
||||
/// Initialize the callbackDispatcher for native callbacks
|
||||
///
|
||||
/// Establishes the methodChannel through which the native side will send its
|
||||
/// callBacks, and the listener that processes the different callback types.
|
||||
///
|
||||
/// This method is called directly from the native platform prior to using
|
||||
/// the [_callbackChannel] to post the actual callback
|
||||
@pragma('vm:entry-point')
|
||||
void initCallbackDispatcher() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
_callbackChannel.setMethodCallHandler((MethodCall call) async {
|
||||
switch (call.method) {
|
||||
case 'beforeTaskStartCallback':
|
||||
final taskJsonString = call.arguments as String;
|
||||
final task = Task.createFromJson(jsonDecode(taskJsonString));
|
||||
final callBack = task.options?.beforeTaskStartCallBack;
|
||||
final taskStatusUpdate = await callBack?.call(task);
|
||||
if (taskStatusUpdate == null) {
|
||||
return null;
|
||||
}
|
||||
return jsonEncode(taskStatusUpdate.toJson());
|
||||
|
||||
case 'onTaskStartCallback':
|
||||
case 'onAuthCallback':
|
||||
final taskJsonString = call.arguments as String;
|
||||
final task = Task.createFromJson(jsonDecode(taskJsonString));
|
||||
final callBack = call.method == 'onTaskStartCallback'
|
||||
? task.options?.onTaskStartCallBack
|
||||
: task.options?.auth?.onAuthCallback;
|
||||
final newTask = await callBack?.call(task);
|
||||
if (newTask == null) {
|
||||
return null;
|
||||
}
|
||||
return jsonEncode(newTask.toJson());
|
||||
|
||||
case 'onTaskFinishedCallback':
|
||||
final taskUpdateJsonString = call.arguments as String;
|
||||
final taskStatusUpdate = TaskStatusUpdate.fromJsonString(
|
||||
taskUpdateJsonString,
|
||||
);
|
||||
final callBack = taskStatusUpdate.task.options?.onTaskFinishedCallBack;
|
||||
await callBack?.call(taskStatusUpdate);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:core';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import 'auth_callback.dart';
|
||||
|
||||
/// Provides an authentication handler for HTTP requests with support for access
|
||||
/// and refresh tokens, customizable headers, and query parameters.
|
||||
///
|
||||
/// This class manages token-based authentication for HTTP requests, allowing
|
||||
/// you to configure access and refresh tokens, as well as related headers and
|
||||
/// query parameters. It supports automatic token refresh if an access token
|
||||
/// expires and facilitates using a refresh token endpoint.
|
||||
///
|
||||
/// To use, :
|
||||
/// - Create a [Task] as with a simple url, without query parameters, and
|
||||
/// no headers. Query parameters and headers will be added based on the
|
||||
/// [Auth] object, that you add to the task via [Task.options]. Include
|
||||
/// [TaskOptions] with the [Auth] object as follows:
|
||||
/// - Create an [Auth] object
|
||||
/// - Add [accessQueryParams] containing all url query parameters. If your auth
|
||||
/// uses query parameters for auth, then use `{accessToken}` to indicate where
|
||||
/// the token should go, eg `{"auth":"{accessToken}"}` will add the access
|
||||
/// token to the ur query parameter auth
|
||||
/// - Add [accessHeaders] containing all headers required for the task. If your
|
||||
/// auth uses headers, then use `{accessToken}` to indicate where the token
|
||||
/// should go, eg `{"Authentication":" Bearer {accessToken}"}` will add a
|
||||
/// typical authentication header for OAuth or JWT based requests.
|
||||
/// - Add information for refresh, eg [refreshToken], [refreshUrl] if you want
|
||||
/// to use the default handler
|
||||
/// - If you use your own handler, add that as `onAuth` - otherwise the refresh
|
||||
/// will be attempted using [defaultOnAuth]
|
||||
///
|
||||
/// For tasks that contain an [Auth] object, the background_downloader will
|
||||
/// first check if the token is expired. If the token is expired, it will call
|
||||
/// the `onAuth` callback, which should refresh the token by calling
|
||||
/// [refreshToken], which will update the [Auth] associated with the task, then
|
||||
/// return the modified task.
|
||||
/// The downloader will substitute the `{accessToken}` placeholder with the
|
||||
/// accessToken in both query parameters and headers before executing the task.
|
||||
///
|
||||
/// ### Key Properties
|
||||
/// - [accessToken]: The current access token used for authentication.
|
||||
/// - [refreshToken]: The refresh token used to obtain a new access token.
|
||||
/// - [accessHeaders]: Headers to include in authenticated requests, with
|
||||
/// optional `{accessToken}` placeholders for dynamic token insertion.
|
||||
/// - [refreshHeaders]: Headers to include when making a refresh token request,
|
||||
/// with option `{refreshToken}` placeholders for dynamic token insertion.
|
||||
/// - [onAuth]: callback to be called before the task starts if access token is
|
||||
/// expired. Callback should return a modified Task, or null to try with the
|
||||
/// original
|
||||
///
|
||||
/// ### Key Methods
|
||||
/// - [getAccessUri]: Returns the URI with authentication query parameters,
|
||||
/// automatically refreshing the access token if needed.
|
||||
/// - [getAccessHeaders]: Returns the headers to be used with the access
|
||||
/// request, with the `{accessToken}` placeholder replaced
|
||||
/// - [refreshAccessToken]: Refreshes the access token using the configured
|
||||
/// refresh token and updates the expiry time. Called by [getAccessUri]
|
||||
/// - [isTokenExpired]: Checks if the access token is nearing expiry.
|
||||
///
|
||||
/// ### Example
|
||||
/// ```dart
|
||||
/// final auth = Auth(
|
||||
/// accessToken: 'initialAccessToken',
|
||||
/// refreshToken: 'initialRefreshToken',
|
||||
/// refreshUrl: 'https://example.com/token/refresh',
|
||||
/// );
|
||||
///
|
||||
/// // Make a request with updated headers and query parameters
|
||||
/// Uri uri = await auth.getAccessUri(url: 'https://example.com/data');
|
||||
/// Map<String, String> headers = auth.getAccessHeaders();
|
||||
/// ```
|
||||
class Auth {
|
||||
static final log = Logger('Auth');
|
||||
|
||||
/// The access token used for authentication
|
||||
String? accessToken;
|
||||
|
||||
/// Headers to use for access requests. Can contain {accessToken} and
|
||||
/// {refreshToken} which will be replaced by the actual tokens
|
||||
Map<String, String> accessHeaders;
|
||||
|
||||
/// Query parameters to use for access requests. Can contain {accessToken} and
|
||||
/// {refreshToken} which will be replaced by the actual tokens
|
||||
Map<String, String> accessQueryParams;
|
||||
|
||||
/// Time when the access token expires
|
||||
DateTime? accessTokenExpiryTime;
|
||||
|
||||
/// The refresh token used to obtain a new access token
|
||||
String? refreshToken;
|
||||
|
||||
/// Headers to use for refresh requests. Can contain {accessToken} and
|
||||
/// {refreshToken} which will be replaced by the actual tokens
|
||||
Map<String, String> refreshHeaders;
|
||||
|
||||
/// Query parameters to use for refresh requests. Can contain {accessToken} and
|
||||
/// {refreshToken} which will be replaced by the actual tokens
|
||||
Map<String, String> refreshQueryParams;
|
||||
|
||||
/// The URL to use for refreshing the token
|
||||
String? refreshUrl;
|
||||
int? _onAuthRawHandle; // for callback
|
||||
|
||||
/// Creates an [Auth] object
|
||||
///
|
||||
/// [accessToken] is the initial access token
|
||||
/// [accessHeaders] are headers to use for access requests
|
||||
/// [accessQueryParams] are query parameters to use for access requests
|
||||
/// [accessTokenExpiryTime] is the time when the access token expires
|
||||
/// [refreshToken] is the initial refresh token
|
||||
/// [refreshUrl] is the URL to use for refreshing the token
|
||||
/// [refreshHeaders] are headers to use for refresh requests
|
||||
/// [refreshQueryParams] are query parameters to use for refresh requests
|
||||
/// [onAuth] is a callback to be called when the access token expires
|
||||
Auth({
|
||||
this.accessToken,
|
||||
this.accessHeaders = const {},
|
||||
this.accessQueryParams = const {},
|
||||
this.accessTokenExpiryTime,
|
||||
this.refreshToken,
|
||||
this.refreshUrl,
|
||||
this.refreshHeaders = const {},
|
||||
this.refreshQueryParams = const {},
|
||||
OnAuthCallback? onAuth,
|
||||
}) : _onAuthRawHandle = onAuth != null
|
||||
? PluginUtilities.getCallbackHandle(onAuth)?.toRawHandle()
|
||||
: null;
|
||||
|
||||
/// Convert the Auth instance to JSON
|
||||
Map<String, dynamic> toJson() => {
|
||||
'accessToken': accessToken,
|
||||
'accessHeaders': accessHeaders,
|
||||
'accessQueryParams': accessQueryParams,
|
||||
'accessTokenExpiryTime': accessTokenExpiryTime?.millisecondsSinceEpoch,
|
||||
'refreshToken': refreshToken,
|
||||
'refreshUrl': refreshUrl,
|
||||
'refreshHeaders': refreshHeaders,
|
||||
'refreshQueryParams': refreshQueryParams,
|
||||
'onAuthRawHandle': _onAuthRawHandle,
|
||||
};
|
||||
|
||||
/// Create an Auth instance from JSON
|
||||
Auth.fromJson(Map<String, dynamic> json)
|
||||
: accessToken = json['accessToken'],
|
||||
accessHeaders = json['accessHeaders'] != null
|
||||
? Map<String, String>.from(json['accessHeaders'])
|
||||
: const {},
|
||||
accessQueryParams = json['accessQueryParams'] != null
|
||||
? Map<String, String>.from(json['accessQueryParams'])
|
||||
: const {},
|
||||
accessTokenExpiryTime = json['accessTokenExpiryTime'] != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(
|
||||
json['accessTokenExpiryTime'] as int,
|
||||
)
|
||||
: null,
|
||||
refreshToken = json['refreshToken'],
|
||||
refreshUrl = json['refreshUrl'],
|
||||
refreshHeaders = json['refreshHeaders'] != null
|
||||
? Map<String, String>.from(json['refreshHeaders'])
|
||||
: const {},
|
||||
refreshQueryParams = json['refreshQueryParams'] != null
|
||||
? Map<String, String>.from(json['refreshQueryParams'])
|
||||
: const {},
|
||||
_onAuthRawHandle = json['onAuthRawHandle'] as int?;
|
||||
|
||||
/// Returns the [OnAuthCallback] registered with this [Auth], or null
|
||||
OnAuthCallback? get onAuthCallback => _onAuthRawHandle != null
|
||||
? PluginUtilities.getCallbackFromHandle(
|
||||
CallbackHandle.fromRawHandle(_onAuthRawHandle!),
|
||||
) as OnAuthCallback
|
||||
: null;
|
||||
|
||||
/// Set the [OnAuthCallback]
|
||||
set onAuthCallback(OnAuthCallback? onAuth) =>
|
||||
_onAuthRawHandle = onAuth != null
|
||||
? PluginUtilities.getCallbackHandle(onAuth)?.toRawHandle()
|
||||
: null;
|
||||
|
||||
/// Returns the Uri for accessing the resource
|
||||
///
|
||||
/// Refreshes the [accessToken] if required, and returns the original
|
||||
/// [url] or [uri] with access query parameters. If headers are used to
|
||||
/// authenticate, then call [getAccessHeaders] after this call, to make sure
|
||||
/// they contain updated access tokens.
|
||||
Future<Uri> getAccessUri({
|
||||
String? url,
|
||||
Uri? uri,
|
||||
http.Client? httpClient,
|
||||
String? refreshUrl,
|
||||
Uri? refreshUri,
|
||||
}) async {
|
||||
if (uri == null && url == null) {
|
||||
throw ArgumentError('Either uri or url must be provided');
|
||||
}
|
||||
final accessUri = uri ?? Uri.parse(url!);
|
||||
if (isTokenExpired()) {
|
||||
// make the refresh request
|
||||
final (updatedAccesstoken, _) = await refreshAccessToken(
|
||||
httpClient: httpClient,
|
||||
refreshUrl: refreshUrl,
|
||||
refreshUri: refreshUri,
|
||||
);
|
||||
if (!updatedAccesstoken) {
|
||||
throw const HttpException('Could not refresh access token');
|
||||
}
|
||||
}
|
||||
return addOrUpdateQueryParams(
|
||||
uri: accessUri,
|
||||
queryParams: getAccessQueryParams(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns the URL string for accessing the resource
|
||||
///
|
||||
/// Refreshes the [accessToken] if required, and returns the original
|
||||
/// [url] or [uri] with access query parameters. If headers are used to
|
||||
/// authenticate, then call [getAccessHeaders] after this call, to make sure
|
||||
/// they contain updated access tokens.
|
||||
Future<String> getAccessUrl({
|
||||
String? url,
|
||||
Uri? uri,
|
||||
http.Client? httpClient,
|
||||
String? refreshUrl,
|
||||
Uri? refreshUri,
|
||||
}) async {
|
||||
final accessUri = await getAccessUri(
|
||||
url: url,
|
||||
uri: uri,
|
||||
httpClient: httpClient,
|
||||
refreshUrl: refreshUrl,
|
||||
refreshUri: refreshUri,
|
||||
);
|
||||
return accessUri.toString();
|
||||
}
|
||||
|
||||
/// Refresh the [accessToken] by calling [refreshUrl] or [refreshUri] using
|
||||
/// [httpClient], adding [refreshHeaders] and [refreshQueryParams]
|
||||
/// to the request and obtaining the token from the json response
|
||||
/// 'access_token' and potential 'expires_in'.
|
||||
/// If the response contains 'refresh_token' that will be updated as well.
|
||||
///
|
||||
/// Returns (bool updatedAccessToken, bool updatedRefreshToken)
|
||||
///
|
||||
/// If neither [refreshUrl] nor [refreshUri] are given, the Auth object's
|
||||
/// [Auth.refreshUrl] is used
|
||||
Future<(bool, bool)> refreshAccessToken({
|
||||
http.Client? httpClient,
|
||||
String? refreshUrl,
|
||||
Uri? refreshUri,
|
||||
}) async {
|
||||
if (refreshUrl == null && refreshUri == null && this.refreshUrl == null) {
|
||||
throw ArgumentError(
|
||||
'refreshUrl, refreshUri or Auth object refreshUrl required',
|
||||
);
|
||||
}
|
||||
var updatedAccessToken = false;
|
||||
var updatedRefreshToken = false;
|
||||
final client = httpClient ?? http.Client();
|
||||
try {
|
||||
final selectedRefreshUri =
|
||||
refreshUri ?? Uri.parse(refreshUrl ?? this.refreshUrl!);
|
||||
final uri = addOrUpdateQueryParams(
|
||||
uri: selectedRefreshUri,
|
||||
queryParams: expandMap(refreshQueryParams),
|
||||
);
|
||||
final headers = expandMap(refreshHeaders);
|
||||
headers['Content-type'] = 'application/json';
|
||||
final response = await client.post(
|
||||
uri,
|
||||
headers: headers,
|
||||
body: jsonEncode({
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': refreshToken,
|
||||
}),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
final newAuthToken = data['access_token'];
|
||||
if (newAuthToken != null) {
|
||||
accessToken = newAuthToken;
|
||||
updatedAccessToken = true;
|
||||
final newExpiresIn = data['expires_in'];
|
||||
if (newExpiresIn != null) {
|
||||
accessTokenExpiryTime = DateTime.now().add(
|
||||
Duration(seconds: newExpiresIn),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
log.fine(
|
||||
'Failed to refresh access token: no access_token in response body.\n'
|
||||
'Body is ${response.body}',
|
||||
);
|
||||
}
|
||||
final newRefreshToken = data['refresh_token'];
|
||||
if (newRefreshToken != null) {
|
||||
refreshToken = newRefreshToken;
|
||||
updatedRefreshToken = true;
|
||||
}
|
||||
} else {
|
||||
log.fine('Failed to refresh token: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
log.warning('Error refreshing token: $e');
|
||||
}
|
||||
return (updatedAccessToken, updatedRefreshToken);
|
||||
}
|
||||
|
||||
/// Returns the headers specified for the access request, with the
|
||||
/// template {accessToken} and {refreshToken} replaced
|
||||
Map<String, String> getAccessHeaders() => expandMap(accessHeaders);
|
||||
|
||||
/// Returns the query params specified for the access request, with the
|
||||
/// template {accessToken} and {refreshToken} replaced
|
||||
Map<String, String> getAccessQueryParams() => expandMap(accessQueryParams);
|
||||
|
||||
/// Add/update the query parameters in this [url] or [uri] with [queryParams]
|
||||
/// and return the new uri
|
||||
Uri addOrUpdateQueryParams({
|
||||
String? url,
|
||||
Uri? uri,
|
||||
Map<String, String> queryParams = const {},
|
||||
}) {
|
||||
if (uri == null && url == null) {
|
||||
throw ArgumentError('Either uri or url must be provided');
|
||||
}
|
||||
final startUri = uri ?? Uri.parse(url!);
|
||||
if (queryParams.isEmpty) {
|
||||
return startUri;
|
||||
}
|
||||
final updatedQueryParams = Map<String, String>.from(
|
||||
startUri.queryParameters,
|
||||
);
|
||||
queryParams.forEach((key, value) {
|
||||
updatedQueryParams[key] = value;
|
||||
});
|
||||
return startUri.replace(queryParameters: updatedQueryParams);
|
||||
}
|
||||
|
||||
/// Returns true if the [accessTokenExpiryTime is after now plus
|
||||
/// the [bufferTime], otherwise returns false
|
||||
bool isTokenExpired({Duration bufferTime = const Duration(seconds: 10)}) {
|
||||
if (accessTokenExpiryTime == null) return false;
|
||||
return DateTime.now().add(bufferTime).isAfter(accessTokenExpiryTime!);
|
||||
}
|
||||
|
||||
/// Expands the [mapToExpand] by replacing {accessToken} and {refreshToken}
|
||||
///
|
||||
/// Returns the expanded map, without changing the original
|
||||
Map<String, String> expandMap(Map<String, String> mapToExpand) {
|
||||
final (access, refresh) = (accessToken, refreshToken);
|
||||
final newMap = <String, String>{};
|
||||
mapToExpand.forEach((key, value) {
|
||||
var newValue = value;
|
||||
if (access != null) {
|
||||
newValue = newValue.replaceAll('{accessToken}', access);
|
||||
}
|
||||
if (refresh != null) {
|
||||
newValue = newValue.replaceAll('{refreshToken}', refresh);
|
||||
}
|
||||
newMap[key] = newValue;
|
||||
});
|
||||
return newMap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'dart:io';
|
||||
|
||||
import '../task.dart';
|
||||
|
||||
typedef OnAuthCallback = Future<Task?> Function(Task original);
|
||||
|
||||
/// Default onAuth callback
|
||||
///
|
||||
/// Called when a task starts and the task has an auth object and the
|
||||
/// object indicated that the access token has expired.
|
||||
/// The callback attempts to refresh the access token using the information
|
||||
/// contained in the [Auth] object, and returns the task, which now contains an
|
||||
/// updated [Auth] object
|
||||
@pragma("vm:entry-point")
|
||||
Future<Task?> defaultOnAuth(Task task) async {
|
||||
final auth = task.options?.auth;
|
||||
if (auth == null) {
|
||||
throw ArgumentError('Task has no auth object');
|
||||
}
|
||||
final (updatedAccessToken, updatedRefreshToken) = await auth
|
||||
.refreshAccessToken();
|
||||
if (!updatedAccessToken) {
|
||||
throw const HttpException('Could not refresh access token');
|
||||
}
|
||||
return task;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import 'dart:io';
|
||||
|
||||
/// Holds configuration options for mutual TLS (mTLS) client authentication.
|
||||
///
|
||||
/// Use with [Config.mTLS] to configure client certificate authentication for
|
||||
/// HTTPS connections on desktop platforms.
|
||||
///
|
||||
/// Provide either file paths ([certificatePath], [privateKeyPath]) or raw bytes
|
||||
/// ([certificateBytes], [privateKeyBytes]) for the client certificate and private key.
|
||||
/// An optional private key [password] can be supplied if required.
|
||||
///
|
||||
/// An optional [host] can be specified to restrict this configuration to a specific host
|
||||
/// (e.g. `'api.example.com'`). If [host] is `null`, the mTLS configuration applies globally
|
||||
/// (or as the default for all hosts).
|
||||
///
|
||||
/// To reset/clear mTLS for a host or globally, pass an [MTLSConfig] instance with no credentials
|
||||
/// (or `null`/`false` in configuration).
|
||||
final class MTLSConfig {
|
||||
/// Optional host (domain or IP) to which this mTLS configuration applies.
|
||||
/// If null, applies to all hosts / default.
|
||||
final String? host;
|
||||
|
||||
/// Path to client certificate / chain file (PEM or DER format).
|
||||
final String? certificatePath;
|
||||
|
||||
/// Bytes of client certificate / chain file (PEM or DER format).
|
||||
final List<int>? certificateBytes;
|
||||
|
||||
/// Path to private key file (PEM format).
|
||||
final String? privateKeyPath;
|
||||
|
||||
/// Bytes of private key file (PEM format).
|
||||
final List<int>? privateKeyBytes;
|
||||
|
||||
/// Optional password for decrypting the private key or certificate.
|
||||
final String? password;
|
||||
|
||||
/// Optional path to trusted server / CA certificate file.
|
||||
final String? serverCertificatePath;
|
||||
|
||||
/// Optional bytes of trusted server / CA certificate file.
|
||||
final List<int>? serverCertificateBytes;
|
||||
|
||||
/// Creates an [MTLSConfig] instance.
|
||||
///
|
||||
/// Asserts that parameters are legally combined (e.g. not providing both file path
|
||||
/// and raw bytes for the same asset, and ensuring both certificate and key are provided
|
||||
/// when configuring credentials).
|
||||
const MTLSConfig({
|
||||
this.host,
|
||||
this.certificatePath,
|
||||
this.certificateBytes,
|
||||
this.privateKeyPath,
|
||||
this.privateKeyBytes,
|
||||
this.password,
|
||||
this.serverCertificatePath,
|
||||
this.serverCertificateBytes,
|
||||
}) : assert(
|
||||
!(certificatePath != null && certificateBytes != null),
|
||||
'Cannot provide both certificatePath and certificateBytes',
|
||||
),
|
||||
assert(
|
||||
!(privateKeyPath != null && privateKeyBytes != null),
|
||||
'Cannot provide both privateKeyPath and privateKeyBytes',
|
||||
),
|
||||
assert(
|
||||
!(serverCertificatePath != null && serverCertificateBytes != null),
|
||||
'Cannot provide both serverCertificatePath and serverCertificateBytes',
|
||||
),
|
||||
assert(
|
||||
((certificatePath != null || certificateBytes != null) &&
|
||||
(privateKeyPath != null || privateKeyBytes != null)) ||
|
||||
((certificatePath == null && certificateBytes == null) &&
|
||||
(privateKeyPath == null && privateKeyBytes == null)),
|
||||
'Both certificate and privateKey must be provided together, or both omitted for reset',
|
||||
);
|
||||
|
||||
/// Returns true if client certificate and key credentials are specified.
|
||||
bool get hasCredentials =>
|
||||
(certificatePath != null || certificateBytes != null) &&
|
||||
(privateKeyPath != null || privateKeyBytes != null);
|
||||
|
||||
/// Returns true if this configuration is a reset configuration (no credentials).
|
||||
bool get isReset => !hasCredentials;
|
||||
|
||||
/// Applies this mTLS configuration to the given [context].
|
||||
void applyToSecurityContext(SecurityContext context) {
|
||||
if (certificatePath != null) {
|
||||
context.useCertificateChain(certificatePath!, password: password);
|
||||
} else if (certificateBytes != null) {
|
||||
context.useCertificateChainBytes(certificateBytes!, password: password);
|
||||
}
|
||||
|
||||
if (privateKeyPath != null) {
|
||||
context.usePrivateKey(privateKeyPath!, password: password);
|
||||
} else if (privateKeyBytes != null) {
|
||||
context.usePrivateKeyBytes(privateKeyBytes!, password: password);
|
||||
}
|
||||
|
||||
if (serverCertificatePath != null) {
|
||||
context.setTrustedCertificates(
|
||||
serverCertificatePath!,
|
||||
password: password,
|
||||
);
|
||||
} else if (serverCertificateBytes != null) {
|
||||
context.setTrustedCertificatesBytes(
|
||||
serverCertificateBytes!,
|
||||
password: password,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is MTLSConfig &&
|
||||
runtimeType == other.runtimeType &&
|
||||
host == other.host &&
|
||||
certificatePath == other.certificatePath &&
|
||||
_listEquals(certificateBytes, other.certificateBytes) &&
|
||||
privateKeyPath == other.privateKeyPath &&
|
||||
_listEquals(privateKeyBytes, other.privateKeyBytes) &&
|
||||
password == other.password &&
|
||||
serverCertificatePath == other.serverCertificatePath &&
|
||||
_listEquals(serverCertificateBytes, other.serverCertificateBytes);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
host.hashCode ^
|
||||
certificatePath.hashCode ^
|
||||
Object.hashAll(certificateBytes ?? []) ^
|
||||
privateKeyPath.hashCode ^
|
||||
Object.hashAll(privateKeyBytes ?? []) ^
|
||||
password.hashCode ^
|
||||
serverCertificatePath.hashCode ^
|
||||
Object.hashAll(serverCertificateBytes ?? []);
|
||||
}
|
||||
|
||||
bool _listEquals(List<int>? a, List<int>? b) {
|
||||
if (a == null) return b == null;
|
||||
if (b == null || a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import '../models.dart';
|
||||
import '../task.dart';
|
||||
import 'auth.dart';
|
||||
|
||||
typedef BeforeTaskStartCallback = Future<TaskStatusUpdate?> Function(Task task);
|
||||
|
||||
typedef OnTaskStartCallback = Future<Task?> Function(Task original);
|
||||
|
||||
typedef OnTaskFinishedCallback = Future<void> Function(
|
||||
TaskStatusUpdate taskStatusUpdate,
|
||||
);
|
||||
|
||||
/// Holds various options related to the task that are not included in the
|
||||
/// task's properties, as they are rare
|
||||
class TaskOptions {
|
||||
final int? _beforeTaskStartRawHandle;
|
||||
final int? _onTaskStartRawHandle;
|
||||
final int? _onTaskFinishedRawHandle;
|
||||
|
||||
/// The [Auth] object associated with this task
|
||||
final Auth? auth;
|
||||
|
||||
/// Constructor for [TaskOptions], containing "native" callbacks:
|
||||
///
|
||||
/// * `beforeTaskStart`: a callback called before a task starts executing.
|
||||
/// The callback receives the `Task` and returns `null` if the task should
|
||||
/// continue, or a `TaskStatusUpdate` if it should not start - in which
|
||||
/// case the `TaskStatusUpdate` is posted as the last state update for the task
|
||||
/// * `onTaskStart`: a callback called before a task starts executing.
|
||||
/// The callback receives the `Task` and returns `null` if it did not
|
||||
/// change anything, or a modified `Task` if it needs to use a different
|
||||
/// url or header. It is called after `onAuth` for token refresh, if that is set
|
||||
/// * `onTaskFinished`: a callback called when the task has finished.
|
||||
/// The callback receives the final `TaskStatusUpdate`.
|
||||
/// * `auth`: an [Auth] object that facilitates management of authorization
|
||||
/// tokens and refresh tokens, and includes an `onAuth` callback similar to
|
||||
/// `onTaskStart`
|
||||
TaskOptions({
|
||||
BeforeTaskStartCallback? beforeTaskStart,
|
||||
OnTaskStartCallback? onTaskStart,
|
||||
OnTaskFinishedCallback? onTaskFinished,
|
||||
this.auth,
|
||||
}) : _beforeTaskStartRawHandle = beforeTaskStart != null
|
||||
? PluginUtilities.getCallbackHandle(beforeTaskStart)?.toRawHandle()
|
||||
: null,
|
||||
_onTaskStartRawHandle = onTaskStart != null
|
||||
? PluginUtilities.getCallbackHandle(onTaskStart)?.toRawHandle()
|
||||
: null,
|
||||
_onTaskFinishedRawHandle = onTaskFinished != null
|
||||
? PluginUtilities.getCallbackHandle(onTaskFinished)?.toRawHandle()
|
||||
: null {
|
||||
assert(
|
||||
beforeTaskStart == null || _beforeTaskStartRawHandle != null,
|
||||
_notTopLevel('beforeTaskStart'),
|
||||
);
|
||||
assert(
|
||||
onTaskStart == null || _onTaskStartRawHandle != null,
|
||||
_notTopLevel('onTaskStart'),
|
||||
);
|
||||
assert(
|
||||
onTaskFinished == null || _onTaskFinishedRawHandle != null,
|
||||
_notTopLevel('onTaskFinished'),
|
||||
);
|
||||
}
|
||||
|
||||
static String _notTopLevel(String callbackName) =>
|
||||
'$callbackName callback must be a top level or static function, '
|
||||
'and marked with @pragma("vm:entry-point")';
|
||||
|
||||
/// Create the object from JSON
|
||||
TaskOptions.fromJson(Map<String, dynamic> json)
|
||||
: _beforeTaskStartRawHandle = json['beforeTaskStartRawHandle'] as int?,
|
||||
_onTaskStartRawHandle = json['onTaskStartRawHandle'] as int?,
|
||||
_onTaskFinishedRawHandle = json['onTaskFinishedRawHandle'] as int?,
|
||||
auth = json['auth'] != null ? Auth.fromJson(json['auth']) : null;
|
||||
|
||||
/// Returns the [BeforeTaskStartCallback] registered with this [TaskOptions], or null
|
||||
BeforeTaskStartCallback? get beforeTaskStartCallBack =>
|
||||
_beforeTaskStartRawHandle != null
|
||||
? PluginUtilities.getCallbackFromHandle(
|
||||
CallbackHandle.fromRawHandle(_beforeTaskStartRawHandle),
|
||||
) as BeforeTaskStartCallback
|
||||
: null;
|
||||
|
||||
/// Returns the [OnTaskStartCallback] registered with this [TaskOptions], or null
|
||||
OnTaskStartCallback? get onTaskStartCallBack => _onTaskStartRawHandle != null
|
||||
? PluginUtilities.getCallbackFromHandle(
|
||||
CallbackHandle.fromRawHandle(_onTaskStartRawHandle),
|
||||
) as OnTaskStartCallback
|
||||
: null;
|
||||
|
||||
/// Returns the [OnTaskFinishedCallback] registered with this [TaskOptions], or null
|
||||
OnTaskFinishedCallback? get onTaskFinishedCallBack =>
|
||||
_onTaskFinishedRawHandle != null
|
||||
? PluginUtilities.getCallbackFromHandle(
|
||||
CallbackHandle.fromRawHandle(_onTaskFinishedRawHandle),
|
||||
) as OnTaskFinishedCallback
|
||||
: null;
|
||||
|
||||
/// True if [TaskOptions] contains any callback
|
||||
bool get hasCallback =>
|
||||
beforeTaskStartCallBack != null ||
|
||||
onTaskStartCallBack != null ||
|
||||
onTaskFinishedCallBack != null ||
|
||||
auth?.onAuthCallback != null;
|
||||
|
||||
/// Creates JSON map of this object
|
||||
Map<String, dynamic> toJson() => {
|
||||
'beforeTaskStartRawHandle': _beforeTaskStartRawHandle,
|
||||
'onTaskStartRawHandle': _onTaskStartRawHandle,
|
||||
'onTaskFinishedRawHandle': _onTaskFinishedRawHandle,
|
||||
'auth': auth?.toJson(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import 'native_downloader.dart';
|
||||
|
||||
enum PermissionType {
|
||||
notifications,
|
||||
androidSharedStorage,
|
||||
iosAddToPhotoLibrary,
|
||||
iosChangePhotoLibrary,
|
||||
}
|
||||
|
||||
enum PermissionStatus { undetermined, denied, granted, partial, requestError }
|
||||
|
||||
/// Developer visible interface to the PermissionService
|
||||
abstract interface class Permissions {
|
||||
/// Request a permission; returns the [PermissionStatus]
|
||||
///
|
||||
/// Ensure that only one request is being handled at the same time,
|
||||
/// i.e. wait for the result before requesting another permission
|
||||
Future<PermissionStatus> request(PermissionType permissionType);
|
||||
|
||||
/// Get the auth status of a permission; returns the [PermissionStatus]
|
||||
Future<PermissionStatus> status(PermissionType permissionType);
|
||||
|
||||
/// Returns true if the developer should show a rationale for
|
||||
/// requesting this permission
|
||||
Future<bool> shouldShowRationale(PermissionType permissionType);
|
||||
}
|
||||
|
||||
/// Basic implementation of the PermissionsService
|
||||
base class PermissionsService implements Permissions {
|
||||
final log = Logger("PermissionService");
|
||||
|
||||
PermissionsService();
|
||||
|
||||
/// Creates a [PermissionsService] appropriate for this platform
|
||||
factory PermissionsService.instance() => switch (defaultTargetPlatform) {
|
||||
.android => AndroidPermissionsService(),
|
||||
.iOS => IOSPermissionsService(),
|
||||
.linux || .macOS || .windows => PermissionsService(),
|
||||
_ => throw ArgumentError(
|
||||
'Platform $defaultTargetPlatform is not supported',
|
||||
),
|
||||
};
|
||||
|
||||
@override
|
||||
Future<PermissionStatus> request(PermissionType permissionType) =>
|
||||
Future.value(PermissionStatus.granted);
|
||||
|
||||
@override
|
||||
Future<bool> shouldShowRationale(PermissionType permissionType) =>
|
||||
Future.value(false);
|
||||
|
||||
@override
|
||||
Future<PermissionStatus> status(PermissionType permissionType) =>
|
||||
Future.value(PermissionStatus.granted);
|
||||
|
||||
/// Process the permissionRequest result sent from the native side
|
||||
/// (Android only)
|
||||
void onPermissionRequestResult(PermissionStatus permissionStatus) {}
|
||||
}
|
||||
|
||||
final class IOSPermissionsService extends PermissionsService {
|
||||
IOSPermissionsService();
|
||||
|
||||
@override
|
||||
Future<PermissionStatus> request(PermissionType permissionType) async {
|
||||
final result = await NativeDownloader.methodChannel.invokeMethod<int>(
|
||||
'requestPermission',
|
||||
permissionType.index,
|
||||
);
|
||||
return result != null
|
||||
? PermissionStatus.values[result]
|
||||
: PermissionStatus.requestError;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PermissionStatus> status(PermissionType permissionType) async {
|
||||
final result = await NativeDownloader.methodChannel.invokeMethod<int>(
|
||||
'permissionStatus',
|
||||
permissionType.index,
|
||||
);
|
||||
return result != null
|
||||
? PermissionStatus.values[result]
|
||||
: PermissionStatus.requestError;
|
||||
}
|
||||
}
|
||||
|
||||
final class AndroidPermissionsService extends IOSPermissionsService {
|
||||
Completer<PermissionStatus>? permissionStatusCompleter;
|
||||
|
||||
@override
|
||||
Future<PermissionStatus> request(PermissionType permissionType) async {
|
||||
if (![
|
||||
PermissionType.notifications,
|
||||
PermissionType.androidSharedStorage,
|
||||
].contains(permissionType)) {
|
||||
return PermissionStatus.granted;
|
||||
}
|
||||
if (permissionStatusCompleter != null) {
|
||||
log.warning(
|
||||
'Permission request already in progress - failing this one immediately',
|
||||
);
|
||||
return PermissionStatus.requestError;
|
||||
}
|
||||
final request = Completer<PermissionStatus>();
|
||||
permissionStatusCompleter = request;
|
||||
try {
|
||||
final waitForCompletion = await NativeDownloader.methodChannel
|
||||
.invokeMethod<bool>('requestPermission', permissionType.index);
|
||||
if (waitForCompletion == true) {
|
||||
// The callback can clear the shared slot before invokeMethod replies.
|
||||
return await request.future;
|
||||
}
|
||||
log.warning(
|
||||
'Failure to request for permission - was it already granted?',
|
||||
);
|
||||
return PermissionStatus.requestError;
|
||||
} finally {
|
||||
// Release only this request's slot, including platform dispatch errors.
|
||||
if (identical(permissionStatusCompleter, request)) {
|
||||
permissionStatusCompleter = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> shouldShowRationale(PermissionType permissionType) async {
|
||||
final result = await NativeDownloader.methodChannel.invokeMethod<bool>(
|
||||
'shouldShowPermissionRationale',
|
||||
permissionType.index,
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
void onPermissionRequestResult(PermissionStatus permissionStatus) {
|
||||
permissionStatusCompleter?.complete(permissionStatus);
|
||||
permissionStatusCompleter = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'base_downloader.dart';
|
||||
import 'database.dart';
|
||||
import 'localstore/localstore.dart';
|
||||
import 'models.dart';
|
||||
import 'task.dart';
|
||||
|
||||
/// Interface for the persistent storage used to back the downloader
|
||||
///
|
||||
/// Defines 'store', 'retrieve', 'retrieveAll' and 'remove' methods for:
|
||||
/// - [TaskRecord]s, keyed by taskId
|
||||
/// - paused [Task]s, keyed by taskId
|
||||
/// - [ResumeData], keyed by taskId
|
||||
///
|
||||
/// Each of the objects has a toJson method and can be created using
|
||||
/// fromJson (use .createFromJson for [Task] objects)
|
||||
///
|
||||
/// Also defined methods to allow migration from one database version to another
|
||||
abstract interface class PersistentStorage {
|
||||
/// Store a [TaskRecord], keyed by taskId
|
||||
Future<void> storeTaskRecord(TaskRecord record);
|
||||
|
||||
/// Retrieve [TaskRecord] with [taskId], or null if not found
|
||||
Future<TaskRecord?> retrieveTaskRecord(String taskId);
|
||||
|
||||
/// Retrieve all [TaskRecord]
|
||||
Future<List<TaskRecord>> retrieveAllTaskRecords();
|
||||
|
||||
/// Remove [TaskRecord] with [taskId] from storage. If null, remove all
|
||||
Future<void> removeTaskRecord(String? taskId);
|
||||
|
||||
/// Store a paused [task], keyed by taskId
|
||||
Future<void> storePausedTask(Task task);
|
||||
|
||||
/// Retrieve paused [Task] with [taskId], or null if not found
|
||||
Future<Task?> retrievePausedTask(String taskId);
|
||||
|
||||
/// Retrieve all paused [Task]
|
||||
Future<List<Task>> retrieveAllPausedTasks();
|
||||
|
||||
/// Remove paused [Task] with [taskId] from storage. If null, remove all
|
||||
Future<void> removePausedTask(String? taskId);
|
||||
|
||||
/// Store [ResumeData], keyed by its taskId
|
||||
Future<void> storeResumeData(ResumeData resumeData);
|
||||
|
||||
/// Retrieve [ResumeData] with [taskId], or null if not found
|
||||
Future<ResumeData?> retrieveResumeData(String taskId);
|
||||
|
||||
/// Retrieve all [ResumeData]
|
||||
Future<List<ResumeData>> retrieveAllResumeData();
|
||||
|
||||
/// Remove [ResumeData] with [taskId] from storage. If null, remove all
|
||||
Future<void> removeResumeData(String? taskId);
|
||||
|
||||
/// Name and version number for this type of persistent storage
|
||||
///
|
||||
/// Used for database migration: this is the version represented by the code
|
||||
(String, int) get currentDatabaseVersion;
|
||||
|
||||
/// Name and version number for database as stored
|
||||
///
|
||||
/// Used for database migration, may be 'older' than the code version
|
||||
Future<(String, int)> get storedDatabaseVersion;
|
||||
|
||||
/// Initialize the database - only called when the [BaseDownloader]
|
||||
/// is created with this object, which happens when the [FileDownloader]
|
||||
/// singleton is instantiated, OR as part of a migration away from this
|
||||
/// database type.
|
||||
///
|
||||
/// Migrates the data from stored name and version to the current
|
||||
/// name and version, if needed
|
||||
/// This call runs async with the rest of the initialization
|
||||
Future<void> initialize();
|
||||
}
|
||||
|
||||
enum _StorageCommand {
|
||||
getStoredDatabaseVersion,
|
||||
initialize,
|
||||
storeTaskRecord,
|
||||
retrieveTaskRecord,
|
||||
retrieveAllTaskRecords,
|
||||
removeTaskRecord,
|
||||
storePausedTask,
|
||||
retrievePausedTask,
|
||||
retrieveAllPausedTasks,
|
||||
removePausedTask,
|
||||
storeResumeData,
|
||||
retrieveResumeData,
|
||||
retrieveAllResumeData,
|
||||
removeResumeData,
|
||||
retrieveAll,
|
||||
clearCache,
|
||||
}
|
||||
|
||||
/// Default implementation of [PersistentStorage] using Localstore package
|
||||
///
|
||||
/// Runs the actual [Localstore] based storage on a background isolate to
|
||||
/// prevent jank on the main thread
|
||||
class LocalStorePersistentStorage implements PersistentStorage {
|
||||
static const taskRecordsPath =
|
||||
_LocalStorePersistentStorageExecutor.taskRecordsPath;
|
||||
static const resumeDataPath =
|
||||
_LocalStorePersistentStorageExecutor.resumeDataPath;
|
||||
static const pausedTasksPath =
|
||||
_LocalStorePersistentStorageExecutor.pausedTasksPath;
|
||||
|
||||
final log = Logger('LocalStorePersistentStorage');
|
||||
SendPort? _sendPort;
|
||||
final _responseCompleters = <int, Completer<dynamic>>{};
|
||||
int _nextRequestId = 0;
|
||||
|
||||
@override
|
||||
(String, int) get currentDatabaseVersion => ('Localstore', 1);
|
||||
|
||||
@override
|
||||
Future<(String, int)> get storedDatabaseVersion async {
|
||||
final result = await _sendRequest<List<dynamic>>(
|
||||
_StorageCommand.getStoredDatabaseVersion,
|
||||
[],
|
||||
);
|
||||
return (result[0] as String, result[1] as int);
|
||||
}
|
||||
|
||||
Future<void>? _initializationFuture;
|
||||
|
||||
@override
|
||||
Future<void> initialize() {
|
||||
if (_sendPort != null) {
|
||||
return Future.value();
|
||||
}
|
||||
_initializationFuture ??= _doInitialize();
|
||||
return _initializationFuture!;
|
||||
}
|
||||
|
||||
Future<void> _doInitialize() async {
|
||||
try {
|
||||
final receivePort = ReceivePort();
|
||||
await Isolate.spawn(_isolateEntry, receivePort.sendPort);
|
||||
_sendPort = await receivePort.first as SendPort;
|
||||
|
||||
// Listen for responses
|
||||
final responsePort = ReceivePort();
|
||||
_sendPort!.send(responsePort.sendPort); // Send response port to isolate
|
||||
responsePort.listen(_handleResponse);
|
||||
|
||||
// Initialize the executor in the isolate
|
||||
// pass the RootIsolateToken to allow background isolate to use platform channels
|
||||
// for path_provider
|
||||
final rootIsolateToken = RootIsolateToken.instance;
|
||||
await _sendRequest(_StorageCommand.initialize, [rootIsolateToken]);
|
||||
} catch (e) {
|
||||
_initializationFuture = null; // allow retry
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleResponse(dynamic message) {
|
||||
if (message is List && message.length >= 2) {
|
||||
final requestId = message[0] as int;
|
||||
final response = message[1];
|
||||
final error = message.length > 2 ? message[2] : null;
|
||||
|
||||
final completer = _responseCompleters.remove(requestId);
|
||||
if (completer != null) {
|
||||
if (error != null) {
|
||||
completer.completeError(error);
|
||||
} else {
|
||||
completer.complete(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<T> _sendRequest<T>(_StorageCommand method, List<dynamic> args) async {
|
||||
if (_sendPort == null) {
|
||||
await initialize();
|
||||
}
|
||||
final completer = Completer<T>();
|
||||
final requestId = _nextRequestId++;
|
||||
_responseCompleters[requestId] = completer;
|
||||
_sendPort!.send([requestId, method, args]);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> storeTaskRecord(TaskRecord record) =>
|
||||
_sendRequest(_StorageCommand.storeTaskRecord, [record.toJson()]);
|
||||
|
||||
@override
|
||||
Future<TaskRecord?> retrieveTaskRecord(String taskId) async {
|
||||
final result = await _sendRequest<TaskRecord?>(
|
||||
_StorageCommand.retrieveTaskRecord,
|
||||
[taskId],
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<TaskRecord>> retrieveAllTaskRecords() async {
|
||||
final result = await _sendRequest<List<dynamic>>(
|
||||
_StorageCommand.retrieveAllTaskRecords,
|
||||
[],
|
||||
);
|
||||
return result.cast<TaskRecord>();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removeTaskRecord(String? taskId) =>
|
||||
_sendRequest(_StorageCommand.removeTaskRecord, [taskId]);
|
||||
|
||||
@override
|
||||
Future<void> storePausedTask(Task task) =>
|
||||
_sendRequest(_StorageCommand.storePausedTask, [task.toJson()]);
|
||||
|
||||
@override
|
||||
Future<Task?> retrievePausedTask(String taskId) async {
|
||||
final result = await _sendRequest<Task?>(
|
||||
_StorageCommand.retrievePausedTask,
|
||||
[taskId],
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Task>> retrieveAllPausedTasks() async {
|
||||
final result = await _sendRequest<List<dynamic>>(
|
||||
_StorageCommand.retrieveAllPausedTasks,
|
||||
[],
|
||||
);
|
||||
return result.cast<Task>();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removePausedTask(String? taskId) =>
|
||||
_sendRequest(_StorageCommand.removePausedTask, [taskId]);
|
||||
|
||||
@override
|
||||
Future<void> storeResumeData(ResumeData resumeData) =>
|
||||
_sendRequest(_StorageCommand.storeResumeData, [resumeData.toJson()]);
|
||||
|
||||
@override
|
||||
Future<ResumeData?> retrieveResumeData(String taskId) async {
|
||||
final result = await _sendRequest<ResumeData?>(
|
||||
_StorageCommand.retrieveResumeData,
|
||||
[taskId],
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ResumeData>> retrieveAllResumeData() async {
|
||||
final result = await _sendRequest<List<dynamic>>(
|
||||
_StorageCommand.retrieveAllResumeData,
|
||||
[],
|
||||
);
|
||||
return result.cast<ResumeData>();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removeResumeData(String? taskId) =>
|
||||
_sendRequest(_StorageCommand.removeResumeData, [taskId]);
|
||||
|
||||
Future<Map<String, dynamic>> retrieveAll(String collection) async {
|
||||
final result = await _sendRequest<Map<String, dynamic>>(
|
||||
_StorageCommand.retrieveAll,
|
||||
[collection],
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> clearCache() => _sendRequest(_StorageCommand.clearCache, []);
|
||||
}
|
||||
|
||||
// Entry point for the isolate
|
||||
void _isolateEntry(SendPort mainSendPort) {
|
||||
final receivePort = ReceivePort();
|
||||
mainSendPort.send(receivePort.sendPort);
|
||||
|
||||
SendPort? responseSendPort;
|
||||
_LocalStorePersistentStorageExecutor? executor;
|
||||
|
||||
receivePort.listen((message) async {
|
||||
if (message is SendPort) {
|
||||
responseSendPort = message;
|
||||
} else if (message is List) {
|
||||
final requestId = message[0] as int;
|
||||
final method = message[1] as _StorageCommand;
|
||||
final args = message[2] as List<dynamic>;
|
||||
|
||||
try {
|
||||
dynamic result;
|
||||
if (method == _StorageCommand.initialize) {
|
||||
final token = args[0] as RootIsolateToken?;
|
||||
if (token != null) {
|
||||
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
|
||||
}
|
||||
if (token != null) {
|
||||
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
|
||||
}
|
||||
executor = _LocalStorePersistentStorageExecutor();
|
||||
await executor!.initialize();
|
||||
executor!.log.fine(
|
||||
'Initialized isolate with dbDir ${await Localstore.instance.databaseDirectory}',
|
||||
);
|
||||
result = null;
|
||||
} else {
|
||||
// All other methods require executor to be initialized
|
||||
if (executor == null) {
|
||||
throw StateError('Executor not initialized');
|
||||
}
|
||||
result = await _dispatch(executor!, method, args);
|
||||
}
|
||||
responseSendPort?.send([requestId, result]);
|
||||
} catch (e) {
|
||||
responseSendPort?.send([requestId, null, e.toString()]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<dynamic> _dispatch(
|
||||
_LocalStorePersistentStorageExecutor executor,
|
||||
_StorageCommand method,
|
||||
List<dynamic> args,
|
||||
) => switch (method) {
|
||||
_StorageCommand.getStoredDatabaseVersion =>
|
||||
executor.storedDatabaseVersion.then((r) => [r.$1, r.$2]), // tuple to list
|
||||
_StorageCommand.storeTaskRecord => executor.storeTaskRecord(
|
||||
args[0] as Map<String, dynamic>,
|
||||
),
|
||||
_StorageCommand.retrieveTaskRecord => executor.retrieveTaskRecord(
|
||||
args[0] as String,
|
||||
),
|
||||
_StorageCommand.retrieveAllTaskRecords => executor.retrieveAllTaskRecords(),
|
||||
_StorageCommand.removeTaskRecord => executor.removeTaskRecord(
|
||||
args[0] as String?,
|
||||
),
|
||||
_StorageCommand.storePausedTask => executor.storePausedTask(
|
||||
args[0] as Map<String, dynamic>,
|
||||
),
|
||||
_StorageCommand.retrievePausedTask => executor.retrievePausedTask(
|
||||
args[0] as String,
|
||||
),
|
||||
_StorageCommand.retrieveAllPausedTasks => executor.retrieveAllPausedTasks(),
|
||||
_StorageCommand.removePausedTask => executor.removePausedTask(
|
||||
args[0] as String?,
|
||||
),
|
||||
_StorageCommand.storeResumeData => executor.storeResumeData(
|
||||
args[0] as Map<String, dynamic>,
|
||||
),
|
||||
_StorageCommand.retrieveResumeData => executor.retrieveResumeData(
|
||||
args[0] as String,
|
||||
),
|
||||
_StorageCommand.retrieveAllResumeData => executor.retrieveAllResumeData(),
|
||||
_StorageCommand.removeResumeData => executor.removeResumeData(
|
||||
args[0] as String?,
|
||||
),
|
||||
_StorageCommand.retrieveAll => executor.retrieveAll(args[0] as String),
|
||||
_StorageCommand.clearCache => executor.clearCache(),
|
||||
_StorageCommand.initialize => throw StateError(
|
||||
'Initialize should be handled in isolate entry',
|
||||
),
|
||||
};
|
||||
|
||||
/// The executor that runs in the isolate and does the actual work
|
||||
class _LocalStorePersistentStorageExecutor {
|
||||
final log = Logger('LocalStorePersistentStorageExecutor');
|
||||
final _db = Localstore.instance;
|
||||
final _illegalPathCharacters = RegExp(r'[\\/:*?"<>|]');
|
||||
|
||||
static const taskRecordsPath = 'backgroundDownloaderTaskRecords';
|
||||
static const resumeDataPath = 'backgroundDownloaderResumeData';
|
||||
static const pausedTasksPath = 'backgroundDownloaderPausedTasks';
|
||||
static const metaDataCollection = 'backgroundDownloaderDatabase';
|
||||
|
||||
Future<void> storeTaskRecord(Map<String, dynamic> recordJson) async {
|
||||
final taskId = recordJson['taskId'] as String;
|
||||
await store(recordJson, taskRecordsPath, _safeId(taskId));
|
||||
}
|
||||
|
||||
Future<TaskRecord?> retrieveTaskRecord(String taskId) async {
|
||||
final json = await retrieve(taskRecordsPath, _safeId(taskId));
|
||||
return json != null ? TaskRecord.fromJson(json) : null;
|
||||
}
|
||||
|
||||
Future<List<TaskRecord>> retrieveAllTaskRecords() async {
|
||||
final jsonMaps = await retrieveAll(taskRecordsPath);
|
||||
return jsonMaps.values
|
||||
.map((e) => TaskRecord.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> removeTaskRecord(String? taskId) =>
|
||||
remove(taskRecordsPath, _safeIdOrNull(taskId));
|
||||
|
||||
Future<void> storePausedTask(Map<String, dynamic> taskJson) async {
|
||||
final taskId = taskJson['taskId'] as String;
|
||||
await store(taskJson, pausedTasksPath, _safeId(taskId));
|
||||
}
|
||||
|
||||
Future<Task?> retrievePausedTask(String taskId) async {
|
||||
final json = await retrieve(pausedTasksPath, _safeId(taskId));
|
||||
return json != null ? Task.createFromJson(json) : null;
|
||||
}
|
||||
|
||||
Future<List<Task>> retrieveAllPausedTasks() async {
|
||||
final jsonMaps = await retrieveAll(pausedTasksPath);
|
||||
return jsonMaps.values
|
||||
.map((e) => Task.createFromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> removePausedTask(String? taskId) =>
|
||||
remove(pausedTasksPath, _safeIdOrNull(taskId));
|
||||
|
||||
Future<void> storeResumeData(Map<String, dynamic> dataJson) async {
|
||||
final taskMap = dataJson['task'] as Map<String, dynamic>;
|
||||
final taskId = taskMap['taskId'] as String;
|
||||
await store(dataJson, resumeDataPath, _safeId(taskId));
|
||||
}
|
||||
|
||||
Future<ResumeData?> retrieveResumeData(String taskId) async {
|
||||
final json = await retrieve(resumeDataPath, _safeId(taskId));
|
||||
return json != null ? ResumeData.fromJson(json) : null;
|
||||
}
|
||||
|
||||
Future<List<ResumeData>> retrieveAllResumeData() async {
|
||||
final jsonMaps = await retrieveAll(resumeDataPath);
|
||||
return jsonMaps.values
|
||||
.map((e) => ResumeData.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> removeResumeData(String? taskId) =>
|
||||
remove(resumeDataPath, _safeIdOrNull(taskId));
|
||||
|
||||
/// Stores [Map<String, dynamic>] formatted [document] in [collection] keyed under [identifier]
|
||||
Future<void> store(
|
||||
Map<String, dynamic> document,
|
||||
String collection,
|
||||
String identifier,
|
||||
) async {
|
||||
await _db.collection(collection).doc(identifier).set(document);
|
||||
}
|
||||
|
||||
/// Returns [document] stored in [collection] under key [identifier]
|
||||
/// as a [Map<String, dynamic>], or null if not found
|
||||
Future<Map<String, dynamic>?> retrieve(
|
||||
String collection,
|
||||
String identifier,
|
||||
) => _db.collection(collection).doc(identifier).get();
|
||||
|
||||
/// Returns all documents in collection as a [Map<String, dynamic>] keyed by the
|
||||
/// document identifier, with the value a [Map<String, dynamic>] representing the document
|
||||
Future<Map<String, dynamic>> retrieveAll(String collection) async =>
|
||||
await _db.collection(collection).get() ?? {};
|
||||
|
||||
/// Removes document with [identifier] from [collection]
|
||||
///
|
||||
/// If [identifier] is null, removes all documents in the [collection]
|
||||
Future<void> remove(String collection, [String? identifier]) async {
|
||||
if (identifier == null) {
|
||||
await _db.collection(collection).delete();
|
||||
} else {
|
||||
await _db.collection(collection).doc(identifier).delete();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns possibly modified id, safe for storing in the localStore
|
||||
String _safeId(String id) => id.replaceAll(_illegalPathCharacters, '_');
|
||||
|
||||
/// Returns possibly modified id, safe for storing in the localStore, or null
|
||||
/// if [id] is null
|
||||
String? _safeIdOrNull(String? id) =>
|
||||
id?.replaceAll(_illegalPathCharacters, '_');
|
||||
|
||||
Future<(String, int)> get storedDatabaseVersion async {
|
||||
final metaData = await _db
|
||||
.collection(metaDataCollection)
|
||||
.doc('metaData')
|
||||
.get();
|
||||
return ('Localstore', (metaData?['version'] as num?)?.toInt() ?? 0);
|
||||
}
|
||||
|
||||
(String, int) get currentDatabaseVersion => ('Localstore', 1);
|
||||
|
||||
Future<void> initialize() async {
|
||||
final (currentName, currentVersion) = currentDatabaseVersion;
|
||||
final (storedName, storedVersion) = await storedDatabaseVersion;
|
||||
if (storedName != currentName) {
|
||||
log.warning('Cannot migrate from database name $storedName');
|
||||
return;
|
||||
}
|
||||
if (storedVersion == currentVersion) {
|
||||
return;
|
||||
}
|
||||
log.fine(
|
||||
'Migrating $currentName database from version $storedVersion to $currentVersion',
|
||||
);
|
||||
switch (storedVersion) {
|
||||
case 0:
|
||||
// move files from docDir to supportDir
|
||||
final docDir = await getApplicationDocumentsDirectory();
|
||||
final supportDir = await getApplicationSupportDirectory();
|
||||
await Future.wait(
|
||||
[resumeDataPath, pausedTasksPath, taskRecordsPath].map((path) async {
|
||||
try {
|
||||
final fromPath = join(docDir.path, path);
|
||||
if (await Directory(fromPath).exists()) {
|
||||
log.finest('Moving $path to support directory');
|
||||
final toPath = join(supportDir.path, path);
|
||||
await Directory(toPath).create(recursive: true);
|
||||
final entities = await Directory(fromPath).list().toList();
|
||||
await Future.wait(
|
||||
entities.whereType<File>().map(
|
||||
(file) => file.copy(join(toPath, basename(file.path))),
|
||||
),
|
||||
);
|
||||
await Directory(fromPath).delete(recursive: true);
|
||||
}
|
||||
} catch (e) {
|
||||
log.fine('Error migrating database for path $path: $e');
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
default:
|
||||
log.warning('Illegal starting version: $storedVersion');
|
||||
}
|
||||
await _db.collection(metaDataCollection).doc('metaData').set({
|
||||
'version': currentVersion,
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> clearCache() async {
|
||||
Localstore.instance.clearCache();
|
||||
}
|
||||
}
|
||||
|
||||
/// Interface to migrate from one persistent storage to another
|
||||
abstract interface class PersistentStorageMigrator {
|
||||
/// Migrate data from one of the [migrationOptions] to the [toStorage]
|
||||
///
|
||||
/// If migration took place, returns the name of the migration option,
|
||||
/// otherwise returns null
|
||||
Future<String?> migrate(
|
||||
List<String> migrationOptions,
|
||||
PersistentStorage toStorage,
|
||||
);
|
||||
}
|
||||
|
||||
/// Migrates from [LocalStorePersistentStorage] to another [PersistentStorage]
|
||||
class BasePersistentStorageMigrator implements PersistentStorageMigrator {
|
||||
final log = Logger('PersistentStorageMigrator');
|
||||
|
||||
/// Create [BasePersistentStorageMigrator] object to migrate between persistent
|
||||
/// storage solutions
|
||||
///
|
||||
/// [BasePersistentStorageMigrator] only migrates from:
|
||||
/// * local_store (the default implementation of the database in
|
||||
/// background_downloader).
|
||||
///
|
||||
/// To add other migrations, extend this class and inject it in the
|
||||
/// [PersistentStorage] class that you want to migrate to.
|
||||
///
|
||||
/// See package background_downloader_sql for an implementation
|
||||
/// that migrates to a SQLite based [PersistentStorage], including
|
||||
/// migration from Flutter Downloader
|
||||
BasePersistentStorageMigrator();
|
||||
|
||||
/// Migrate data from one of the [migrationOptions] to the [toStorage]
|
||||
///
|
||||
/// If migration took place, returns the name of the migration option,
|
||||
/// otherwise returns null
|
||||
///
|
||||
/// This is the public interface to use in other [PersistentStorage]
|
||||
/// solutions.
|
||||
@override
|
||||
Future<String?> migrate(
|
||||
List<String> migrationOptions,
|
||||
PersistentStorage toStorage,
|
||||
) async {
|
||||
for (final persistentStorageName in migrationOptions) {
|
||||
try {
|
||||
if (await migrateFrom(persistentStorageName, toStorage)) {
|
||||
return persistentStorageName;
|
||||
}
|
||||
} on Exception catch (e, stacktrace) {
|
||||
log.warning(
|
||||
'Error attempting to migrate from $persistentStorageName: $e\n$stacktrace',
|
||||
);
|
||||
}
|
||||
}
|
||||
return null; // no migration
|
||||
}
|
||||
|
||||
/// Attempt to migrate data from [persistentStorageName] to [toStorage]
|
||||
///
|
||||
/// Returns true if the migration was successfully executed, false if it
|
||||
/// was not a viable migration
|
||||
///
|
||||
/// If extending the class, add your mapping from a migration option String
|
||||
/// to a _migrateFrom... method that does your migration.
|
||||
Future<bool> migrateFrom(
|
||||
String persistentStorageName,
|
||||
PersistentStorage toStorage,
|
||||
) => switch (persistentStorageName.toLowerCase().replaceAll('_', '')) {
|
||||
'localstore' => migrateFromLocalStore(toStorage),
|
||||
_ => Future.value(false),
|
||||
};
|
||||
|
||||
/// Migrate from a persistent storage to our database
|
||||
///
|
||||
/// Returns true if this migration took place
|
||||
///
|
||||
/// This is a generic migrator that copies from one storage to another, and
|
||||
/// is used by the _migrateFrom... methods
|
||||
Future<bool> migrateFromPersistentStorage(
|
||||
PersistentStorage fromStorage,
|
||||
PersistentStorage toStorage,
|
||||
) async {
|
||||
bool migratedSomething = false;
|
||||
await fromStorage.initialize();
|
||||
final pausedTasks = await fromStorage.retrieveAllPausedTasks();
|
||||
if (pausedTasks.isNotEmpty) {
|
||||
await Future.wait(pausedTasks.map((e) => toStorage.storePausedTask(e)));
|
||||
migratedSomething = true;
|
||||
}
|
||||
final resumeData = await fromStorage.retrieveAllResumeData();
|
||||
if (resumeData.isNotEmpty) {
|
||||
await Future.wait(resumeData.map((e) => toStorage.storeResumeData(e)));
|
||||
migratedSomething = true;
|
||||
}
|
||||
final taskRecords = await fromStorage.retrieveAllTaskRecords();
|
||||
if (taskRecords.isNotEmpty) {
|
||||
await Future.wait(taskRecords.map((e) => toStorage.storeTaskRecord(e)));
|
||||
migratedSomething = true;
|
||||
}
|
||||
return migratedSomething;
|
||||
}
|
||||
|
||||
/// Attempt to migrate from [LocalStorePersistentStorage]
|
||||
///
|
||||
/// Return true if successful. Successful migration removes the original
|
||||
/// data
|
||||
///
|
||||
/// If extending this class, add a method like this that does the
|
||||
/// migration by:
|
||||
/// 1. Setting up the [PersistentStorage] object you want to migrate from
|
||||
/// 2. Call [migrateFromPersistentStorage] to do the transfer from that
|
||||
/// object to the new object, passed as [toStorage]
|
||||
/// 3. Remove all traces of the [PersistentStorage] object you want to migrate
|
||||
/// from
|
||||
Future<bool> migrateFromLocalStore(PersistentStorage toStorage) async {
|
||||
final localStore = LocalStorePersistentStorage();
|
||||
if (await migrateFromPersistentStorage(localStore, toStorage)) {
|
||||
// delete all paths related to LocalStore
|
||||
final supportDir = await getApplicationSupportDirectory();
|
||||
await Future.wait(
|
||||
[
|
||||
_LocalStorePersistentStorageExecutor.resumeDataPath,
|
||||
_LocalStorePersistentStorageExecutor.pausedTasksPath,
|
||||
_LocalStorePersistentStorageExecutor.taskRecordsPath,
|
||||
_LocalStorePersistentStorageExecutor.metaDataCollection,
|
||||
].map((collectionPath) async {
|
||||
try {
|
||||
final path = join(supportDir.path, collectionPath);
|
||||
if (await Directory(path).exists()) {
|
||||
log.finest('Removing directory $path for LocalStore');
|
||||
await Directory(path).delete(recursive: true);
|
||||
}
|
||||
} catch (e) {
|
||||
log.fine('Error deleting collection path $collectionPath: $e');
|
||||
}
|
||||
}),
|
||||
);
|
||||
return true; // we migrated a database
|
||||
}
|
||||
return false; // we did not migrate a database
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'file_downloader.dart';
|
||||
import 'models.dart';
|
||||
import 'task.dart';
|
||||
import 'uri/uri_helpers.dart';
|
||||
|
||||
/// Progress indicator for use with the [FileDownloader]
|
||||
///
|
||||
/// Configuration parameters:
|
||||
/// [message] message to show for a single download. Templates {filename} and
|
||||
/// {metadata} are replaced by a task's filename and metadata respectively
|
||||
/// [collapsedMessage] message to show when multiple file downloads are
|
||||
/// collapsed into a single row. The template {n} is replaced by the number
|
||||
/// of tasks finished, and {total} is replaced by the total number of tasks
|
||||
/// started in this batch. Both reset to 0 when all downloads finish.
|
||||
/// In collapsed mode, progress is indicated as {n}/{total}
|
||||
/// [showPauseButton] if true, shows a pause button if the task allows it
|
||||
/// [showCancelButton] if true, shows a cancel button
|
||||
/// [height] height of the [DownloadProgressIndicator], and of each row when
|
||||
/// in expanded mode
|
||||
/// [maxExpandable] maximum number of rows the indicator can expand to, with
|
||||
/// each row showing one download in progress. If set to 1 (the default) the
|
||||
/// indicator will not expand: if the number of downloads in progress exceeds
|
||||
/// 1, the indicator will switch to a 'collapsed' state showing the
|
||||
/// number of files finished out of the total started. It will stay in
|
||||
/// 'collapsed' state until all downloads have finished. To start the
|
||||
/// indicator in collapsed state (i.e. never show the download progress of a
|
||||
/// single file), set [maxExpandable] to 0
|
||||
/// [backgroundColor] background color for the widget
|
||||
class DownloadProgressIndicator extends StatefulWidget {
|
||||
const DownloadProgressIndicator(
|
||||
this.updates, {
|
||||
this.message = '{filename}',
|
||||
this.collapsedMessage = '{n}/{total}',
|
||||
this.showPauseButton = false,
|
||||
this.showCancelButton = false,
|
||||
this.height = 50,
|
||||
this.maxExpandable = 1,
|
||||
this.backgroundColor = Colors.grey,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Stream<TaskUpdate> updates;
|
||||
final String message;
|
||||
final String collapsedMessage;
|
||||
final bool showPauseButton;
|
||||
final bool showCancelButton;
|
||||
final double height;
|
||||
final int maxExpandable;
|
||||
final Color backgroundColor;
|
||||
|
||||
@override
|
||||
State<DownloadProgressIndicator> createState() =>
|
||||
_DownloadProgressIndicatorState();
|
||||
}
|
||||
|
||||
class _DownloadProgressIndicatorState extends State<DownloadProgressIndicator> {
|
||||
StreamSubscription<TaskUpdate>? downloadUpdates;
|
||||
final inProgress = <Task, (double, int)>{};
|
||||
final pausedTasks = <Task>{};
|
||||
final totalTasks = <Task>{};
|
||||
final finishedTasks = <Task>{};
|
||||
bool isExpanded = false;
|
||||
bool isCollapsed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
isCollapsed = widget.maxExpandable == 0;
|
||||
downloadUpdates = widget.updates.listen((update) {
|
||||
if (update is TaskStatusUpdate) {
|
||||
switch (update.status) {
|
||||
case .running:
|
||||
totalTasks.add(update.task);
|
||||
pausedTasks.remove(update.task);
|
||||
inProgress[update.task] = (
|
||||
0,
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
|
||||
case .waitingToRetry:
|
||||
break;
|
||||
|
||||
case .paused:
|
||||
pausedTasks.add(update.task);
|
||||
|
||||
default:
|
||||
// task finished
|
||||
_finish(update.task);
|
||||
}
|
||||
} else if (update is TaskProgressUpdate) {
|
||||
switch (update.progress) {
|
||||
case >= 0 && < 1:
|
||||
// active, so add task to set and update progress
|
||||
totalTasks.add(update.task);
|
||||
pausedTasks.remove(update.task);
|
||||
final previousInProgress = inProgress[update.task];
|
||||
inProgress[update.task] = (
|
||||
update.progress,
|
||||
previousInProgress?.$2 ?? DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
|
||||
case progressWaitingToRetry:
|
||||
break;
|
||||
|
||||
case progressPaused:
|
||||
pausedTasks.add(update.task);
|
||||
|
||||
default:
|
||||
// task finished
|
||||
_finish(update.task);
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
downloadUpdates?.cancel();
|
||||
downloadUpdates = null;
|
||||
}
|
||||
|
||||
/// Process a finished task
|
||||
///
|
||||
/// Remove the task from [inProgress] and [pausedTasks], add to [finishedTasks]
|
||||
/// If [inProgress] is empty, also empty the sets and reset
|
||||
/// the [isExpanded] and [isCollapsed] flags.
|
||||
void _finish(Task task) {
|
||||
inProgress.remove(task);
|
||||
finishedTasks.add(task);
|
||||
pausedTasks.remove(task);
|
||||
if (inProgress.isEmpty) {
|
||||
totalTasks.clear();
|
||||
finishedTasks.clear();
|
||||
isExpanded = false;
|
||||
isCollapsed = widget.maxExpandable == 0;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final activeTasks = inProgress.keys
|
||||
.where((taskId) => inProgress[taskId]!.$1 >= 0)
|
||||
.sorted((a, b) => inProgress[a]!.$2.compareTo(inProgress[b]!.$2));
|
||||
final numActive = activeTasks.length;
|
||||
if (numActive > 1) {
|
||||
if (widget.maxExpandable > 1) {
|
||||
isExpanded = true; // only resets when all downloads finish
|
||||
} else {
|
||||
isCollapsed = true; // only resets when all downloads finish
|
||||
}
|
||||
}
|
||||
final itemsToShow = isExpanded
|
||||
? min(numActive, widget.maxExpandable)
|
||||
: isCollapsed
|
||||
? min(1, numActive)
|
||||
: numActive;
|
||||
return AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: switch (itemsToShow) {
|
||||
0 => Container(height: 0),
|
||||
1 =>
|
||||
isCollapsed
|
||||
? _CollapsedDownloadProgress(
|
||||
finishedTasks.length,
|
||||
totalTasks.length,
|
||||
widget.collapsedMessage,
|
||||
widget.height,
|
||||
widget.backgroundColor,
|
||||
)
|
||||
: _DownloadProgressItem(
|
||||
activeTasks.first,
|
||||
inProgress[activeTasks.first]!.$1,
|
||||
widget.message,
|
||||
widget.showPauseButton,
|
||||
widget.showCancelButton,
|
||||
widget.height,
|
||||
widget.backgroundColor,
|
||||
pausedTasks,
|
||||
),
|
||||
_ => _ExpandedDownloadProgress(
|
||||
activeTasks.take(widget.maxExpandable).toList(growable: false),
|
||||
widget.message,
|
||||
widget.height,
|
||||
widget.backgroundColor,
|
||||
inProgress,
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final _fileNameRegEx = RegExp("""{filename}""", caseSensitive: false);
|
||||
final _metadataRegEx = RegExp("""{metadata}""", caseSensitive: false);
|
||||
|
||||
/// Single file download progress widget
|
||||
class _DownloadProgressItem extends StatelessWidget {
|
||||
const _DownloadProgressItem(
|
||||
this.task,
|
||||
this.progress,
|
||||
this.message,
|
||||
this.showPauseButton,
|
||||
this.showCancelButton,
|
||||
this.height,
|
||||
this.backgroundColor,
|
||||
this.pausedTasks,
|
||||
);
|
||||
|
||||
final Task task;
|
||||
final double progress;
|
||||
final String message;
|
||||
final bool showPauseButton;
|
||||
final bool showCancelButton;
|
||||
final double height;
|
||||
final Color backgroundColor;
|
||||
final Set<Task> pausedTasks;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final messageText = message
|
||||
.replaceAll(
|
||||
_fileNameRegEx,
|
||||
unpack(task.filename).filename ?? task.filename,
|
||||
)
|
||||
.replaceAll(_metadataRegEx, task.metaData);
|
||||
return Container(
|
||||
height: height,
|
||||
decoration: BoxDecoration(color: backgroundColor),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 8, right: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Text(
|
||||
messageText,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: LinearProgressIndicator(
|
||||
value: progress == 0 ? null : progress,
|
||||
),
|
||||
),
|
||||
if (showPauseButton &&
|
||||
task.allowPause &&
|
||||
!pausedTasks.contains(task))
|
||||
IconButton(
|
||||
onPressed: () => FileDownloader().pause(task as DownloadTask),
|
||||
icon: const Icon(Icons.pause),
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
if (showPauseButton &&
|
||||
task.allowPause &&
|
||||
pausedTasks.contains(task))
|
||||
IconButton(
|
||||
onPressed: () => FileDownloader().resume(task as DownloadTask),
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
if (showCancelButton)
|
||||
IconButton(
|
||||
onPressed: () => FileDownloader().cancelTaskWithId(task.taskId),
|
||||
icon: const Icon(Icons.cancel),
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapsed file download progress widget, showing the number of files
|
||||
/// downloaded out of the total number of files downloaded as a progress
|
||||
/// indicator
|
||||
class _CollapsedDownloadProgress extends StatelessWidget {
|
||||
const _CollapsedDownloadProgress(
|
||||
this.numCompleted,
|
||||
this.numTotal,
|
||||
this.collapsedMessage,
|
||||
this.height,
|
||||
this.backgroundColor,
|
||||
);
|
||||
|
||||
final int numCompleted;
|
||||
final int numTotal;
|
||||
final String collapsedMessage;
|
||||
final double height;
|
||||
final Color backgroundColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final messageText = collapsedMessage
|
||||
.replaceAll('{n}', '$numCompleted')
|
||||
.replaceAll('{total}', '$numTotal');
|
||||
final progress = numCompleted / numTotal;
|
||||
|
||||
return Container(
|
||||
height: height,
|
||||
decoration: BoxDecoration(color: backgroundColor),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 8, right: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Text(
|
||||
messageText,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
Expanded(child: LinearProgressIndicator(value: progress)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Expanded file download progress widget, showing multiple file download
|
||||
/// progress indicators (up to expandable as defined in the core widget
|
||||
class _ExpandedDownloadProgress extends StatelessWidget {
|
||||
const _ExpandedDownloadProgress(
|
||||
this.tasks,
|
||||
this.message,
|
||||
this.height,
|
||||
this.backgroundColor,
|
||||
this.inProgress,
|
||||
);
|
||||
|
||||
final List<Task> tasks;
|
||||
final String message;
|
||||
final double height;
|
||||
final Color backgroundColor;
|
||||
final Map<Task, (double, int)> inProgress;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
border: Border(top: BorderSide(color: Theme.of(context).dividerColor)),
|
||||
),
|
||||
child: Table(
|
||||
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
|
||||
columnWidths: const <int, TableColumnWidth>{
|
||||
0: IntrinsicColumnWidth(),
|
||||
1: FlexColumnWidth(),
|
||||
},
|
||||
children: tasks
|
||||
.map(
|
||||
(task) => TableRow(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(color: Theme.of(context).dividerColor),
|
||||
),
|
||||
),
|
||||
children: [
|
||||
SizedBox(
|
||||
height: height,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 8, right: 8),
|
||||
child: Text(
|
||||
message
|
||||
.replaceAll(
|
||||
_fileNameRegEx,
|
||||
unpack(task.filename).filename ?? task.filename,
|
||||
)
|
||||
.replaceAll(_metadataRegEx, task.metaData),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: LinearProgressIndicator(value: inProgress[task]!.$1),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
/// A queue that processes jobs serially, one after another.
|
||||
///
|
||||
/// [J] is the type of the job data.
|
||||
/// [R] is the type of the result returned by the processor.
|
||||
class SerialJobQueue<J, R>(final Future<R> Function(J) _processor) {
|
||||
final Queue<_Job<J, R>> _queue = Queue();
|
||||
bool _isProcessing = false;
|
||||
|
||||
/// Adds data to the queue and returns a Future that completes
|
||||
/// when this specific item has been processed.
|
||||
Future<R> add(J data) {
|
||||
final completer = Completer<R>();
|
||||
_queue.add(_Job(data, completer));
|
||||
|
||||
// Trigger the processing loop if it's not already running
|
||||
_processQueue();
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
Future<void> _processQueue() async {
|
||||
// If already running, do nothing. The loop will pick up the new item.
|
||||
if (_isProcessing) return;
|
||||
|
||||
_isProcessing = true;
|
||||
|
||||
while (_queue.isNotEmpty) {
|
||||
final job = _queue.removeFirst();
|
||||
try {
|
||||
// Await the processing to ensure order
|
||||
final result = await _processor(job.data);
|
||||
job.completer.complete(result);
|
||||
} catch (e, stack) {
|
||||
job.completer.completeError(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
_isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Simple container for the data and the completer
|
||||
class _Job<J, R>(final J data, final Completer<R> completer);
|
||||
Loaded 100 of 445 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user