[verified] Publish ReelBox source under GPLv3

This commit is contained in:
Hermes committed 2026-11-12 23:41:13 +00:00
commit db2b505ca3
445 files changed
+105274

No files matched your search

+63
View File
@@ -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'
@@ -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>
@@ -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
}
@@ -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)
}
}
}
}
@@ -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
}
}
}
}
@@ -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)
}
}
@@ -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();
}
}
@@ -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}"
)
}
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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 })
}
@@ -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)
}
}
@@ -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
}
@@ -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)
@@ -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)
}
}
@@ -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
)
)
}
}
}
@@ -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
)
@@ -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
//}
@@ -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
}
@@ -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)
}
}
}
}
@@ -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()
}
}
@@ -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
}
}
@@ -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)
}
}
@@ -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()
}
}
}
}
@@ -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
}
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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&param2=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())
}
}
@@ -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))
// }
//}
@@ -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")))
}
}
@@ -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)
}
}