[verified] Publish ReelBox source under GPLv3
This commit is contained in:
commit
db2b505ca3
445 files changed
+105274
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user