mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-08-07 20:38:43 +02:00
Add support for background download and resumption
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
|
||||
<!-- Permissions -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission
|
||||
@@ -98,6 +99,12 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".services.DownloadService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
||||
+308
-127
@@ -1,9 +1,10 @@
|
||||
package com.audiobookshelf.app.managers
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.StatFs
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.audiobookshelf.app.MainActivity
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.device.FolderScanner
|
||||
import com.audiobookshelf.app.models.DownloadItem
|
||||
@@ -14,6 +15,7 @@ import com.getcapacitor.JSObject
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.math.max
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -22,27 +24,36 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.Call
|
||||
|
||||
/** Owns the Android download queue and writes all bytes to app-owned staging files. */
|
||||
/**
|
||||
* Process-owned Android download queue. Every network write goes through app-owned staging and is
|
||||
* admitted only after reserving enough space for the complete operation.
|
||||
*/
|
||||
class DownloadItemManager(
|
||||
private val folderScanner: FolderScanner,
|
||||
private val mainActivity: MainActivity,
|
||||
private val clientEventEmitter: DownloadEventEmitter
|
||||
private val context: Context,
|
||||
private var clientEventEmitter: DownloadEventEmitter
|
||||
) {
|
||||
private val tag = "DownloadItemManager"
|
||||
private val maxSimultaneousDownloads = 3
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val activeCalls = ConcurrentHashMap<String, Call>()
|
||||
/** DocumentsProvider does not make concurrent createDirectory/findFile calls atomic. */
|
||||
private val safFolderLocks = ConcurrentHashMap<String, Any>()
|
||||
private val reservations = mutableMapOf<String, Long>()
|
||||
private val lastPersistTime = mutableMapOf<String, Long>()
|
||||
private var watcherRunning = false
|
||||
private val jacksonMapper =
|
||||
jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
var downloadItemQueue: MutableList<DownloadItem> = mutableListOf()
|
||||
private set
|
||||
var currentDownloadItemParts: MutableList<DownloadItemPart> = mutableListOf()
|
||||
private set
|
||||
|
||||
interface DownloadEventEmitter {
|
||||
fun onDownloadItem(downloadItem: DownloadItem)
|
||||
fun onDownloadItemPartUpdate(downloadItemPart: DownloadItemPart)
|
||||
fun onDownloadItemComplete(jsobj: JSObject)
|
||||
fun onQueueChanged(hasWork: Boolean)
|
||||
}
|
||||
|
||||
interface InternalProgressCallback {
|
||||
@@ -55,49 +66,129 @@ class DownloadItemManager(
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addDownloadItem(downloadItem: DownloadItem) {
|
||||
DeviceManager.dbManager.saveDownloadItem(downloadItem)
|
||||
downloadItemQueue.add(downloadItem)
|
||||
clientEventEmitter.onDownloadItem(downloadItem)
|
||||
checkUpdateDownloadQueue()
|
||||
fun setEventEmitter(eventEmitter: DownloadEventEmitter) {
|
||||
clientEventEmitter = eventEmitter
|
||||
downloadItemQueue.forEach(clientEventEmitter::onDownloadItem)
|
||||
notifyQueueChanged()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun checkUpdateDownloadQueue() {
|
||||
for (downloadItem in downloadItemQueue.toList()) {
|
||||
val availableSlots = maxSimultaneousDownloads - currentDownloadItemParts.size
|
||||
if (availableSlots <= 0) break
|
||||
downloadItem.getNextDownloadItemParts(availableSlots).forEach(::startDownload)
|
||||
fun restoreQueue() {
|
||||
if (downloadItemQueue.isNotEmpty()) return
|
||||
DeviceManager.dbManager.getDownloadItems().forEach { item ->
|
||||
if (item.isDownloadFinished) {
|
||||
downloadItemQueue.add(item)
|
||||
checkDownloadItemFinished(item)
|
||||
return@forEach
|
||||
}
|
||||
item.downloadItemParts.forEach { part ->
|
||||
if (part.moved) return@forEach
|
||||
part.downloadId = null
|
||||
part.isMoving = false
|
||||
part.failed = false
|
||||
part.completed = false
|
||||
part.waitingForSpace = false
|
||||
part.bytesDownloaded = File(part.destinationPath).takeIf(File::exists)?.length() ?: 0L
|
||||
}
|
||||
downloadItemQueue.add(item)
|
||||
clientEventEmitter.onDownloadItem(item)
|
||||
}
|
||||
if (currentDownloadItemParts.isNotEmpty()) startWatchingDownloads()
|
||||
checkUpdateDownloadQueue()
|
||||
notifyQueueChanged()
|
||||
}
|
||||
|
||||
private fun startDownload(part: DownloadItemPart) {
|
||||
@Synchronized
|
||||
fun addDownloadItem(downloadItem: DownloadItem) {
|
||||
if (downloadItemQueue.any { it.id == downloadItem.id }) return
|
||||
persist(downloadItem, force = true)
|
||||
downloadItemQueue.add(downloadItem)
|
||||
clientEventEmitter.onDownloadItem(downloadItem)
|
||||
checkUpdateDownloadQueue()
|
||||
notifyQueueChanged()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun retryAll() {
|
||||
downloadItemQueue.forEach { item ->
|
||||
item.downloadItemParts.filter { it.failed }.forEach { part ->
|
||||
part.failed = false
|
||||
part.completed = false
|
||||
part.isMoving = false
|
||||
part.downloadId = null
|
||||
part.retryCount = 0
|
||||
}
|
||||
persist(item, force = true)
|
||||
}
|
||||
checkUpdateDownloadQueue()
|
||||
notifyQueueChanged()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun cancelAll() {
|
||||
activeCalls.values.forEach(Call::cancel)
|
||||
activeCalls.clear()
|
||||
downloadItemQueue.forEach { item ->
|
||||
item.downloadItemParts.forEach { part -> File(part.destinationPath).delete() }
|
||||
DeviceManager.dbManager.removeDownloadItem(item.id)
|
||||
}
|
||||
currentDownloadItemParts.clear()
|
||||
reservations.clear()
|
||||
downloadItemQueue.clear()
|
||||
notifyQueueChanged()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun hasWork(): Boolean = downloadItemQueue.isNotEmpty()
|
||||
|
||||
@Synchronized
|
||||
private fun checkUpdateDownloadQueue() {
|
||||
downloadItemQueue.toList().forEach { item ->
|
||||
val slots = MAX_SIMULTANEOUS_DOWNLOADS - currentDownloadItemParts.size
|
||||
if (slots <= 0) return@forEach
|
||||
item.getNextDownloadItemParts(slots).forEach { part ->
|
||||
if (tryReserve(part)) startDownload(item, part)
|
||||
else {
|
||||
part.waitingForSpace = true
|
||||
part.lastUpdateTime = System.currentTimeMillis()
|
||||
persist(item)
|
||||
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||
}
|
||||
}
|
||||
}
|
||||
startWatchingDownloads()
|
||||
}
|
||||
|
||||
private fun startDownload(item: DownloadItem, part: DownloadItemPart) {
|
||||
val stagingFile = File(part.destinationPath)
|
||||
stagingFile.parentFile?.mkdirs()
|
||||
part.downloadId = APP_MANAGED_DOWNLOAD_ID
|
||||
part.waitingForSpace = false
|
||||
part.lastUpdateTime = System.currentTimeMillis()
|
||||
currentDownloadItemParts.add(part)
|
||||
val callback =
|
||||
object : InternalProgressCallback {
|
||||
persist(item, force = true)
|
||||
activeCalls[part.id] =
|
||||
InternalDownloadManager(stagingFile, part.fileSize, object : InternalProgressCallback {
|
||||
override fun onProgress(totalBytesWritten: Long, progress: Long) {
|
||||
synchronized(this@DownloadItemManager) {
|
||||
if (part !in currentDownloadItemParts) return
|
||||
part.bytesDownloaded = totalBytesWritten
|
||||
part.progress = progress
|
||||
part.lastUpdateTime = System.currentTimeMillis()
|
||||
persist(item)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onComplete(failed: Boolean) {
|
||||
synchronized(this@DownloadItemManager) {
|
||||
if (part !in currentDownloadItemParts) return
|
||||
part.failed = failed
|
||||
part.completed = true
|
||||
part.completed = !failed
|
||||
part.lastUpdateTime = System.currentTimeMillis()
|
||||
activeCalls.remove(part.id)
|
||||
persist(item, force = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
activeCalls[part.id] = InternalDownloadManager(stagingFile, part.fileSize, callback).download(part.serverUrl)
|
||||
}, { hasAvailableSpace(part) }).download(serverUrl(item, part))
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@@ -107,101 +198,223 @@ class DownloadItemManager(
|
||||
scope.launch {
|
||||
while (true) {
|
||||
val activeParts = synchronized(this@DownloadItemManager) { currentDownloadItemParts.toList() }
|
||||
if (activeParts.isEmpty()) break
|
||||
activeParts.forEach(::handlePartUpdate)
|
||||
synchronized(this@DownloadItemManager) {
|
||||
checkUpdateDownloadQueue()
|
||||
if (downloadItemQueue.isEmpty()) {
|
||||
watcherRunning = false
|
||||
notifyQueueChanged()
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
delay(WATCH_INTERVAL_MS)
|
||||
synchronized(this@DownloadItemManager) { checkUpdateDownloadQueue() }
|
||||
}
|
||||
synchronized(this@DownloadItemManager) { watcherRunning = false }
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePartUpdate(part: DownloadItemPart) {
|
||||
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||
if (!part.completed) {
|
||||
val item = synchronized(this) { downloadItemQueue.find { it.id == part.downloadItemId } } ?: run {
|
||||
removeActivePart(part)
|
||||
return
|
||||
}
|
||||
if (!part.completed && !part.failed) {
|
||||
val lastUpdate = part.lastUpdateTime ?: return
|
||||
if (System.currentTimeMillis() - lastUpdate > STALL_TIMEOUT_MS) {
|
||||
Log.e(tag, "Download stalled: ${part.filename}")
|
||||
Log.w(tag, "Download stalled: ${part.filename}")
|
||||
activeCalls.remove(part.id)?.cancel()
|
||||
synchronized(this) {
|
||||
part.failed = true
|
||||
part.completed = true
|
||||
}
|
||||
failOrRetry(item, part, "Download stalled")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val item = synchronized(this) { downloadItemQueue.find { it.id == part.downloadItemId } }
|
||||
if (item == null) {
|
||||
removeActivePart(part)
|
||||
return
|
||||
}
|
||||
if (part.failed) {
|
||||
removeActivePart(part)
|
||||
failOrRetry(item, part, "Transfer failed")
|
||||
return
|
||||
}
|
||||
if (part.isInternalStorage) finalizeInternalFile(item, part) else moveDownloadedFile(item, part)
|
||||
}
|
||||
|
||||
private fun failOrRetry(item: DownloadItem, part: DownloadItemPart, reason: String) {
|
||||
removeActivePart(part)
|
||||
part.retryCount += 1
|
||||
releaseReservation(part)
|
||||
if (part.retryCount > MAX_RETRIES) {
|
||||
Log.e(tag, "$reason after $MAX_RETRIES retries: ${part.filename}")
|
||||
part.failed = true
|
||||
part.completed = false
|
||||
part.downloadId = null
|
||||
persist(item, force = true)
|
||||
notifyQueueChanged()
|
||||
return
|
||||
}
|
||||
part.failed = false
|
||||
part.completed = false
|
||||
part.downloadId = null
|
||||
part.isMoving = false
|
||||
persist(item, force = true)
|
||||
scope.launch {
|
||||
delay(RETRY_BASE_DELAY_MS * (1L shl (part.retryCount - 1)))
|
||||
synchronized(this@DownloadItemManager) { checkUpdateDownloadQueue() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun finalizeInternalFile(item: DownloadItem, part: DownloadItemPart) {
|
||||
if (part.moved || part.isMoving) return
|
||||
part.isMoving = true
|
||||
val stagingFile = File(part.destinationPath)
|
||||
val finalFile = File(part.finalDestinationPath)
|
||||
finalFile.parentFile?.mkdirs()
|
||||
if (finalFile.exists() && !finalFile.delete()) {
|
||||
failFinalization(item, part, "Could not replace existing internal file")
|
||||
return
|
||||
val backup = File(finalFile.parentFile, ".${finalFile.name}.abs-backup")
|
||||
try {
|
||||
if (backup.exists() && !backup.delete()) throw IllegalStateException("Could not clear backup")
|
||||
if (finalFile.exists() && !finalFile.renameTo(backup)) throw IllegalStateException("Could not protect existing file")
|
||||
if (!stagingFile.renameTo(finalFile)) {
|
||||
if (backup.exists()) backup.renameTo(finalFile)
|
||||
throw IllegalStateException("Could not finalize internal staging file")
|
||||
}
|
||||
backup.delete()
|
||||
completePart(item, part)
|
||||
} catch (e: Exception) {
|
||||
part.isMoving = false
|
||||
part.failed = true
|
||||
failOrRetry(item, part, e.message ?: "Internal finalization failed")
|
||||
}
|
||||
if (!stagingFile.renameTo(finalFile)) {
|
||||
failFinalization(item, part, "Could not finalize internal staging file")
|
||||
return
|
||||
}
|
||||
part.moved = true
|
||||
part.isMoving = false
|
||||
removeActivePart(part)
|
||||
checkDownloadItemFinished(item)
|
||||
}
|
||||
|
||||
private fun moveDownloadedFile(item: DownloadItem, part: DownloadItemPart) {
|
||||
if (part.moved || part.isMoving) return
|
||||
val destinationRoot = DocumentFile.fromTreeUri(mainActivity, Uri.parse(part.localFolderUrl))
|
||||
if (destinationRoot == null) {
|
||||
failFinalization(item, part, "Could not resolve SAF destination")
|
||||
return
|
||||
}
|
||||
val root = DocumentFile.fromTreeUri(context, Uri.parse(part.localFolderUrl))
|
||||
?: return failFinalization(item, part, "Could not resolve SAF destination")
|
||||
part.isMoving = true
|
||||
persist(item, force = true)
|
||||
scope.launch {
|
||||
try {
|
||||
val destinationFolder = getOrCreateFolder(destinationRoot, part.finalDestinationSubfolder)
|
||||
?: throw IllegalStateException("Could not create SAF destination folder")
|
||||
destinationFolder.findFile(part.filename)?.let { existing ->
|
||||
if (!existing.delete()) throw IllegalStateException("Could not replace ${part.filename}")
|
||||
if (!hasAvailableSpace(part)) throw IllegalStateException("Insufficient storage for SAF copy")
|
||||
val folderKey = "${root.uri}/${part.finalDestinationSubfolder}"
|
||||
val folderLock = safFolderLocks.computeIfAbsent(folderKey) { Any() }
|
||||
val folder = synchronized(folderLock) {
|
||||
getOrCreateFolder(root, part.finalDestinationSubfolder)
|
||||
} ?: throw IllegalStateException("Could not create SAF destination folder")
|
||||
val temporaryName = ".${part.filename}.${part.id.hashCode()}.part"
|
||||
folder.findFile(temporaryName)?.delete()
|
||||
val temporary = folder.createFile(mimeTypeFor(part), temporaryName)
|
||||
?: throw IllegalStateException("Could not create SAF temporary file")
|
||||
val staging = File(part.destinationPath)
|
||||
FileInputStream(staging).use { input ->
|
||||
context.contentResolver.openOutputStream(temporary.uri, "w")?.use { input.copyTo(it) }
|
||||
?: throw IllegalStateException("Could not open SAF output stream")
|
||||
}
|
||||
val destinationFile = destinationFolder.createFile(mimeTypeFor(part), part.filename)
|
||||
?: throw IllegalStateException("Could not create ${part.filename}")
|
||||
val stagingFile = File(part.destinationPath)
|
||||
FileInputStream(stagingFile).use { input ->
|
||||
mainActivity.contentResolver.openOutputStream(destinationFile.uri, "w")?.use { output ->
|
||||
input.copyTo(output)
|
||||
} ?: throw IllegalStateException("Could not open SAF output stream")
|
||||
}
|
||||
if (destinationFile.length() != stagingFile.length()) {
|
||||
destinationFile.delete()
|
||||
throw IllegalStateException("SAF copy size mismatch for ${part.filename}")
|
||||
}
|
||||
stagingFile.delete()
|
||||
part.completedDestinationUri = destinationFile.uri.toString()
|
||||
part.moved = true
|
||||
part.isMoving = false
|
||||
removeActivePart(part)
|
||||
checkDownloadItemFinished(item)
|
||||
if (temporary.length() != staging.length()) throw IllegalStateException("SAF copy size mismatch")
|
||||
val existing = folder.findFile(part.filename)
|
||||
if (existing != null && !existing.delete()) throw IllegalStateException("Could not replace existing file")
|
||||
if (!temporary.renameTo(part.filename)) throw IllegalStateException("Could not finalize SAF temporary file")
|
||||
val destination = folder.findFile(part.filename)
|
||||
?: throw IllegalStateException("Could not reopen finalized SAF file")
|
||||
if (destination.length() != staging.length()) throw IllegalStateException("SAF final size mismatch")
|
||||
if (!staging.delete()) Log.w(tag, "Could not remove staging file ${staging.name}")
|
||||
part.completedDestinationUri = destination.uri.toString()
|
||||
completePart(item, part)
|
||||
} catch (e: Exception) {
|
||||
failFinalization(item, part, "SAF copy failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun failFinalization(item: DownloadItem, part: DownloadItemPart, message: String) {
|
||||
Log.e(tag, message)
|
||||
part.isMoving = false
|
||||
part.failed = true
|
||||
failOrRetry(item, part, message)
|
||||
}
|
||||
|
||||
private fun completePart(item: DownloadItem, part: DownloadItemPart) {
|
||||
part.moved = true
|
||||
part.completed = true
|
||||
part.failed = false
|
||||
part.isMoving = false
|
||||
releaseReservation(part)
|
||||
removeActivePart(part)
|
||||
persist(item, force = true)
|
||||
checkDownloadItemFinished(item)
|
||||
}
|
||||
|
||||
private fun checkDownloadItemFinished(item: DownloadItem) {
|
||||
if (!item.isDownloadFinished) return
|
||||
scope.launch {
|
||||
folderScanner.scanDownloadItem(item) { scanResult ->
|
||||
val event = JSObject().apply {
|
||||
put("libraryItemId", item.id)
|
||||
put("localFolderId", item.localFolder.id)
|
||||
scanResult?.localLibraryItem?.let { put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(it))) }
|
||||
scanResult?.localMediaProgress?.let { put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(it))) }
|
||||
}
|
||||
clientEventEmitter.onDownloadItemComplete(event)
|
||||
synchronized(this@DownloadItemManager) {
|
||||
downloadItemQueue.remove(item)
|
||||
DeviceManager.dbManager.removeDownloadItem(item.id)
|
||||
notifyQueueChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun tryReserve(part: DownloadItemPart): Boolean {
|
||||
// Covers from older servers often omit a size. Keep unknown-length work serial and use the
|
||||
// runtime low-space guard rather than leaving those queue items permanently deferred.
|
||||
if (part.fileSize <= 0L && currentDownloadItemParts.any { it.fileSize <= 0L }) return false
|
||||
val staging = File(part.destinationPath)
|
||||
staging.parentFile?.mkdirs()
|
||||
val expectedSize = if (part.fileSize > 0L) part.fileSize else UNKNOWN_PART_RESERVATION_BYTES
|
||||
val remaining = (expectedSize - (staging.takeIf(File::exists)?.length() ?: 0L)).coerceAtLeast(0L)
|
||||
val required = if (part.isInternalStorage) remaining else remaining + expectedSize
|
||||
val key = storageKey(staging)
|
||||
val fs = statFsFor(staging)
|
||||
val headroom = max(MIN_FREE_SPACE_BYTES, fs.totalBytes / 20L)
|
||||
val alreadyReserved = reservations.filterKeys { storageKey(File(it)) == key }.values.sum()
|
||||
if (fs.availableBytes - alreadyReserved < required + headroom) return false
|
||||
reservations[part.destinationPath] = required
|
||||
return true
|
||||
}
|
||||
|
||||
private fun hasAvailableSpace(part: DownloadItemPart): Boolean {
|
||||
val staging = File(part.destinationPath)
|
||||
val fs = statFsFor(staging)
|
||||
return fs.availableBytes >= max(MIN_FREE_SPACE_BYTES, fs.totalBytes / 20L)
|
||||
}
|
||||
|
||||
private fun statFsFor(staging: File): StatFs {
|
||||
var directory = staging.parentFile ?: context.filesDir
|
||||
directory.mkdirs()
|
||||
while (!directory.exists()) directory = directory.parentFile ?: context.filesDir
|
||||
return StatFs(directory.absolutePath)
|
||||
}
|
||||
|
||||
private fun storageKey(file: File): String =
|
||||
if (file.absolutePath.startsWith(context.filesDir.absolutePath)) "internal" else "external"
|
||||
|
||||
private fun releaseReservation(part: DownloadItemPart) { reservations.remove(part.destinationPath) }
|
||||
|
||||
@Synchronized
|
||||
private fun removeActivePart(part: DownloadItemPart) {
|
||||
activeCalls.remove(part.id)
|
||||
currentDownloadItemParts.remove(part)
|
||||
}
|
||||
|
||||
private fun persist(item: DownloadItem, force: Boolean = false) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (!force && now - (lastPersistTime[item.id] ?: 0L) < PERSIST_INTERVAL_MS) return
|
||||
lastPersistTime[item.id] = now
|
||||
DeviceManager.dbManager.saveDownloadItem(item)
|
||||
}
|
||||
|
||||
private fun notifyQueueChanged() { clientEventEmitter.onQueueChanged(downloadItemQueue.isNotEmpty()) }
|
||||
|
||||
fun destroy() {
|
||||
activeCalls.values.forEach(Call::cancel)
|
||||
activeCalls.clear()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
private fun getOrCreateFolder(root: DocumentFile, relativePath: String): DocumentFile? {
|
||||
var current = root
|
||||
relativePath.split('/').filter { it.isNotBlank() }.forEach { segment ->
|
||||
@@ -211,62 +424,30 @@ class DownloadItemManager(
|
||||
return current
|
||||
}
|
||||
|
||||
private fun mimeTypeFor(part: DownloadItemPart): String {
|
||||
return part.audioTrack?.mimeType
|
||||
?: when (part.ebookFile?.ebookFormat?.lowercase()) {
|
||||
"epub" -> "application/epub+zip"
|
||||
"pdf" -> "application/pdf"
|
||||
else -> "image/jpeg"
|
||||
}
|
||||
}
|
||||
private fun mimeTypeFor(part: DownloadItemPart): String =
|
||||
part.audioTrack?.mimeType ?: when (part.ebookFile?.ebookFormat?.lowercase()) {
|
||||
"epub" -> "application/epub+zip"
|
||||
"pdf" -> "application/pdf"
|
||||
else -> "image/jpeg"
|
||||
}
|
||||
|
||||
private fun failFinalization(item: DownloadItem, part: DownloadItemPart, message: String) {
|
||||
Log.e(tag, message)
|
||||
part.failed = true
|
||||
part.isMoving = false
|
||||
part.completed = true
|
||||
removeActivePart(part)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun removeActivePart(part: DownloadItemPart) {
|
||||
activeCalls.remove(part.id)
|
||||
currentDownloadItemParts.remove(part)
|
||||
}
|
||||
|
||||
private fun checkDownloadItemFinished(downloadItem: DownloadItem) {
|
||||
if (!downloadItem.isDownloadFinished) return
|
||||
scope.launch {
|
||||
folderScanner.scanDownloadItem(downloadItem) { scanResult ->
|
||||
val event =
|
||||
JSObject().apply {
|
||||
put("libraryItemId", downloadItem.id)
|
||||
put("localFolderId", downloadItem.localFolder.id)
|
||||
scanResult?.localLibraryItem?.let {
|
||||
put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(it)))
|
||||
}
|
||||
scanResult?.localMediaProgress?.let {
|
||||
put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(it)))
|
||||
}
|
||||
}
|
||||
clientEventEmitter.onDownloadItemComplete(event)
|
||||
synchronized(this@DownloadItemManager) {
|
||||
downloadItemQueue.remove(downloadItem)
|
||||
DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
activeCalls.values.forEach(Call::cancel)
|
||||
activeCalls.clear()
|
||||
scope.cancel()
|
||||
private fun serverUrl(item: DownloadItem, part: DownloadItemPart): String {
|
||||
val token = DeviceManager.deviceData.serverConnectionConfigs
|
||||
.find { it.id == item.serverConnectionConfigId }?.token ?: DeviceManager.token
|
||||
var url = "${item.serverAddress}${part.serverPath}?token=$token"
|
||||
if (part.serverPath.endsWith("/cover")) url += "&raw=1"
|
||||
return url
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val APP_MANAGED_DOWNLOAD_ID = -1L
|
||||
const val WATCH_INTERVAL_MS = 500L
|
||||
const val MAX_SIMULTANEOUS_DOWNLOADS = 3
|
||||
const val WATCH_INTERVAL_MS = 1_000L
|
||||
const val STALL_TIMEOUT_MS = 60_000L
|
||||
const val RETRY_BASE_DELAY_MS = 5_000L
|
||||
const val MAX_RETRIES = 5
|
||||
const val PERSIST_INTERVAL_MS = 2_000L
|
||||
const val MIN_FREE_SPACE_BYTES = 100L * 1024L * 1024L
|
||||
const val UNKNOWN_PART_RESERVATION_BYTES = 100L * 1024L * 1024L
|
||||
}
|
||||
}
|
||||
|
||||
+18
-9
@@ -15,16 +15,10 @@ import okhttp3.Response
|
||||
class InternalDownloadManager(
|
||||
private val destinationFile: File,
|
||||
private val expectedSize: Long,
|
||||
private val progressCallback: DownloadItemManager.InternalProgressCallback
|
||||
private val progressCallback: DownloadItemManager.InternalProgressCallback,
|
||||
private val hasAvailableSpace: () -> Boolean
|
||||
) {
|
||||
private val tag = "InternalDownloadManager"
|
||||
private val client =
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.writeTimeout(60, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Returns the active call so the queue can cancel a stalled transfer. A partial staging file is
|
||||
* retained only when the server proves that it honoured a subsequent range request.
|
||||
@@ -51,6 +45,11 @@ class InternalDownloadManager(
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
try {
|
||||
if (response.code == 416 && expectedSize > 0L && existingBytes == expectedSize) {
|
||||
progressCallback.onProgress(existingBytes, 100L)
|
||||
progressCallback.onComplete(false)
|
||||
return
|
||||
}
|
||||
val append = existingBytes > 0L && response.code == 206 && hasExpectedRange(response, existingBytes)
|
||||
if (existingBytes > 0L && !append && response.code != 200) {
|
||||
Log.e(tag, "Invalid resume response ${response.code} for offset $existingBytes")
|
||||
@@ -77,6 +76,7 @@ class InternalDownloadManager(
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
if (!hasAvailableSpace()) throw IOException("Download paused to preserve free storage")
|
||||
output.write(buffer, 0, read)
|
||||
totalBytes += read
|
||||
val progress = if (totalLength > 0L) (totalBytes * 100L) / totalLength else 0L
|
||||
@@ -104,10 +104,19 @@ class InternalDownloadManager(
|
||||
|
||||
private fun hasExpectedRange(response: Response, offset: Long): Boolean {
|
||||
val range = response.header("Content-Range") ?: return false
|
||||
return range.startsWith("bytes $offset-")
|
||||
val match = CONTENT_RANGE.matchEntire(range) ?: return false
|
||||
return match.groupValues[1].toLongOrNull() == offset &&
|
||||
match.groupValues[2].toLongOrNull()?.let { it >= offset } == true
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CHUNK_SIZE = 8 * 1024
|
||||
val CONTENT_RANGE = Regex("bytes (\\d+)-(\\d+)/(?:\\d+|\\*)")
|
||||
val client =
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.writeTimeout(60, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ data class DownloadItem(
|
||||
if (limit == 0) return itemParts
|
||||
|
||||
for (it in downloadItemParts) {
|
||||
if (!it.completed && it.downloadId == null) {
|
||||
if (!it.completed && !it.failed && it.downloadId == null) {
|
||||
itemParts.add(it)
|
||||
if (itemParts.size >= limit) break
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ data class DownloadItemPart(
|
||||
val filename: String,
|
||||
val fileSize: Long,
|
||||
/** App-owned staging location. This is intentionally a String so it survives process storage. */
|
||||
val destinationPath: String,
|
||||
@JsonIgnore val destinationPath: String,
|
||||
val finalDestinationPath:String,
|
||||
val serverPath: String,
|
||||
val localFolderName: String,
|
||||
@@ -33,12 +33,16 @@ data class DownloadItemPart(
|
||||
@JsonIgnore val destinationUri: Uri,
|
||||
@JsonIgnore val finalDestinationUri: Uri,
|
||||
/** Final SAF document returned by the provider after a successful move. */
|
||||
/** Persisted Android-only SAF URI used to reopen a completed document after process recovery. */
|
||||
@JsonIgnore var completedDestinationUri: String?,
|
||||
val finalDestinationSubfolder: String,
|
||||
var downloadId: Long?,
|
||||
var lastUpdateTime: Long?,
|
||||
@JsonIgnore var lastUpdateTime: Long?,
|
||||
var progress: Long,
|
||||
var bytesDownloaded: Long
|
||||
var bytesDownloaded: Long,
|
||||
/** Android queue state; hidden from the shared Capacitor download-part payload. */
|
||||
@JsonIgnore var retryCount: Int = 0,
|
||||
@JsonIgnore var waitingForSpace: Boolean = false
|
||||
) {
|
||||
companion object {
|
||||
fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, ebookFile: EBookFile?, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart {
|
||||
@@ -87,6 +91,11 @@ data class DownloadItemPart(
|
||||
val isInternalStorage get() = localFolderId.startsWith("internal-")
|
||||
|
||||
@get:JsonIgnore
|
||||
val serverUrl get() = uri.toString()
|
||||
val serverUrl: String
|
||||
get() {
|
||||
var url = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
|
||||
if (serverPath.endsWith("/cover")) url += "&raw=1"
|
||||
return url
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.audiobookshelf.app.models.DownloadItem
|
||||
import com.audiobookshelf.app.models.DownloadItemPart
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import com.audiobookshelf.app.managers.DownloadItemManager
|
||||
import com.audiobookshelf.app.services.DownloadServiceHost
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.getcapacitor.JSObject
|
||||
@@ -39,20 +40,36 @@ class AbsDownloader : Plugin() {
|
||||
override fun onDownloadItemComplete(jsobj:JSObject) {
|
||||
notifyListeners("onItemDownloadComplete", jsobj)
|
||||
}
|
||||
override fun onQueueChanged(hasWork: Boolean) = Unit
|
||||
})
|
||||
|
||||
override fun load() {
|
||||
mainActivity = (activity as MainActivity)
|
||||
folderScanner = FolderScanner(mainActivity)
|
||||
apiHandler = ApiHandler(mainActivity)
|
||||
downloadItemManager = DownloadItemManager(folderScanner, mainActivity, clientEventEmitter)
|
||||
downloadItemManager = DownloadServiceHost.ensure(mainActivity)
|
||||
DownloadServiceHost.attachBridge(mainActivity, clientEventEmitter)
|
||||
}
|
||||
|
||||
override fun handleOnDestroy() {
|
||||
if (::downloadItemManager.isInitialized) downloadItemManager.destroy()
|
||||
DownloadServiceHost.detachBridge()
|
||||
super.handleOnDestroy()
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue restoration happens before the WebView mounts. Replay its parent items when Vue registers
|
||||
* the listener so subsequent part updates always have a matching store entry.
|
||||
*/
|
||||
@PluginMethod(returnType = PluginMethod.RETURN_NONE)
|
||||
override fun addListener(call: PluginCall) {
|
||||
super.addListener(call)
|
||||
if (call.getString("eventName") == "onDownloadItem" && ::downloadItemManager.isInitialized) {
|
||||
downloadItemManager.downloadItemQueue.forEach { item ->
|
||||
notifyListeners("onDownloadItem", JSObject(jacksonMapper.writeValueAsString(item)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun downloadLibraryItem(call: PluginCall) {
|
||||
val libraryItemId = call.data.getString("libraryItemId").toString()
|
||||
@@ -163,11 +180,6 @@ class AbsDownloader : Plugin() {
|
||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "ebook file already exists, removing it from ${finalDestinationFile.absolutePath}")
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,ebookFile,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
@@ -187,11 +199,6 @@ class AbsDownloader : Plugin() {
|
||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}")
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,audioTrack,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
@@ -207,16 +214,11 @@ class AbsDownloader : Plugin() {
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Cover already exists, removing it from ${finalDestinationFile.absolutePath}")
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
downloadItemManager.addDownloadItem(downloadItem)
|
||||
DownloadServiceHost.enqueue(mainActivity, downloadItem)
|
||||
}
|
||||
} else {
|
||||
// Podcast episode download
|
||||
@@ -237,11 +239,6 @@ class AbsDownloader : Plugin() {
|
||||
|
||||
var destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||
var finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}")
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,audioTrack,episode)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
|
||||
@@ -255,15 +252,11 @@ class AbsDownloader : Plugin() {
|
||||
destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||
finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Podcast cover already exists - not downloading cover again")
|
||||
} else {
|
||||
downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
downloadItemManager.addDownloadItem(downloadItem)
|
||||
DownloadServiceHost.enqueue(mainActivity, downloadItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.audiobookshelf.app.services
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.models.DownloadItemPart
|
||||
|
||||
/** Android-owned foreground lifecycle for transfers that must outlive the WebView and Activity. */
|
||||
class DownloadService : Service() {
|
||||
private var lastPart: DownloadItemPart? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createChannel()
|
||||
startForeground(NOTIFICATION_ID, notification("Preparing downloads"))
|
||||
DownloadServiceHost.attachService(this)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_CANCEL -> DownloadServiceHost.cancelAll(this)
|
||||
ACTION_RETRY -> DownloadServiceHost.retryAll(this)
|
||||
else -> DownloadServiceHost.ensure(this)
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
DownloadServiceHost.detachService(this)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
fun onPartUpdate(part: DownloadItemPart) {
|
||||
lastPart = part
|
||||
val text = if (part.waitingForSpace) "Waiting for available storage" else "Downloading ${part.filename}"
|
||||
val progress = part.progress.coerceIn(0L, 100L).toInt()
|
||||
val notification = notification(text, progress, part.fileSize > 0L)
|
||||
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager).notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
fun onQueueChanged(hasWork: Boolean) {
|
||||
if (!hasWork) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
private fun notification(text: String, progress: Int = 0, determinate: Boolean = false): Notification {
|
||||
val cancelIntent = PendingIntent.getService(
|
||||
this, 1, Intent(this, DownloadService::class.java).setAction(ACTION_CANCEL), pendingIntentFlags())
|
||||
val retryIntent = PendingIntent.getService(
|
||||
this, 2, Intent(this, DownloadService::class.java).setAction(ACTION_RETRY), pendingIntentFlags())
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.icon)
|
||||
.setContentTitle("Audiobookshelf downloads")
|
||||
.setContentText(text)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setOngoing(true)
|
||||
.setProgress(100, progress, !determinate)
|
||||
.addAction(0, "Cancel", cancelIntent)
|
||||
.addAction(0, "Retry", retryIntent)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Downloads", NotificationManager.IMPORTANCE_LOW))
|
||||
}
|
||||
|
||||
private fun pendingIntentFlags(): Int = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "downloads"
|
||||
private const val NOTIFICATION_ID = 4102
|
||||
private const val ACTION_CANCEL = "com.audiobookshelf.app.download.CANCEL"
|
||||
private const val ACTION_RETRY = "com.audiobookshelf.app.download.RETRY"
|
||||
fun intent(context: Context) = Intent(context, DownloadService::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.audiobookshelf.app.services
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.audiobookshelf.app.device.FolderScanner
|
||||
import com.audiobookshelf.app.managers.DbManager
|
||||
import com.audiobookshelf.app.managers.DownloadItemManager
|
||||
import com.audiobookshelf.app.models.DownloadItem
|
||||
import com.getcapacitor.JSObject
|
||||
import java.util.Collections
|
||||
|
||||
/** Shared process owner used by the foreground service and the Capacitor bridge. */
|
||||
object DownloadServiceHost {
|
||||
private var manager: DownloadItemManager? = null
|
||||
private var bridgeEmitter: DownloadItemManager.DownloadEventEmitter = NoopEmitter
|
||||
private var service: DownloadService? = null
|
||||
@Volatile private var bridgeReady = false
|
||||
private val deferredCompletions = Collections.synchronizedList(mutableListOf<JSObject>())
|
||||
|
||||
@Synchronized
|
||||
fun ensure(context: Context): DownloadItemManager {
|
||||
if (manager == null) {
|
||||
val appContext = context.applicationContext
|
||||
DbManager.initialize(appContext)
|
||||
manager = DownloadItemManager(FolderScanner(appContext), appContext, ForwardingEmitter)
|
||||
manager!!.restoreQueue()
|
||||
}
|
||||
return manager!!
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun attachBridge(context: Context, emitter: DownloadItemManager.DownloadEventEmitter) {
|
||||
// Rehydrate the frontend's parent items before allowing part-progress events through.
|
||||
// Otherwise a running restored queue can emit a part before Vue knows its DownloadItem.
|
||||
bridgeReady = false
|
||||
bridgeEmitter = emitter
|
||||
val queue = ensure(context)
|
||||
queue.setEventEmitter(ForwardingEmitter)
|
||||
bridgeReady = true
|
||||
val completions = synchronized(deferredCompletions) {
|
||||
deferredCompletions.toList().also { deferredCompletions.clear() }
|
||||
}
|
||||
completions.forEach(bridgeEmitter::onDownloadItemComplete)
|
||||
if (queue.hasWork()) startService(context)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun detachBridge() {
|
||||
bridgeReady = false
|
||||
bridgeEmitter = NoopEmitter
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun enqueue(context: Context, item: DownloadItem) {
|
||||
ensure(context).addDownloadItem(item)
|
||||
startService(context)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun retryAll(context: Context) {
|
||||
startService(context)
|
||||
ensure(context).retryAll()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun cancelAll(context: Context) { ensure(context).cancelAll() }
|
||||
|
||||
@Synchronized
|
||||
fun attachService(downloadService: DownloadService) {
|
||||
service = downloadService
|
||||
service?.onQueueChanged(ensure(downloadService).hasWork())
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun detachService(downloadService: DownloadService) {
|
||||
if (service === downloadService) service = null
|
||||
}
|
||||
|
||||
private fun startService(context: Context) {
|
||||
ContextCompat.startForegroundService(context, DownloadService.intent(context))
|
||||
}
|
||||
|
||||
private object ForwardingEmitter : DownloadItemManager.DownloadEventEmitter {
|
||||
override fun onDownloadItem(downloadItem: DownloadItem) { bridgeEmitter.onDownloadItem(downloadItem) }
|
||||
override fun onDownloadItemPartUpdate(downloadItemPart: com.audiobookshelf.app.models.DownloadItemPart) {
|
||||
if (bridgeReady) bridgeEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
||||
service?.onPartUpdate(downloadItemPart)
|
||||
}
|
||||
override fun onDownloadItemComplete(jsobj: JSObject) {
|
||||
if (bridgeReady) bridgeEmitter.onDownloadItemComplete(jsobj) else deferredCompletions.add(jsobj)
|
||||
}
|
||||
override fun onQueueChanged(hasWork: Boolean) {
|
||||
bridgeEmitter.onQueueChanged(hasWork)
|
||||
service?.onQueueChanged(hasWork)
|
||||
}
|
||||
}
|
||||
|
||||
private object NoopEmitter : DownloadItemManager.DownloadEventEmitter {
|
||||
override fun onDownloadItem(downloadItem: DownloadItem) = Unit
|
||||
override fun onDownloadItemPartUpdate(downloadItemPart: com.audiobookshelf.app.models.DownloadItemPart) = Unit
|
||||
override fun onDownloadItemComplete(jsobj: JSObject) = Unit
|
||||
override fun onQueueChanged(hasWork: Boolean) = Unit
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user