Add support for background download and resumption

This commit is contained in:
Nicholas Wallace
2026-07-18 22:56:12 -07:00
parent eb2483d039
commit 6aeb31b591
8 changed files with 562 additions and 171 deletions
+7
View File
@@ -6,6 +6,7 @@
<!-- Permissions --> <!-- Permissions -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <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.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission <uses-permission
@@ -98,6 +99,12 @@
</intent-filter> </intent-filter>
</service> </service>
<service
android:name=".services.DownloadService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider" android:authorities="${applicationId}.fileprovider"
@@ -1,9 +1,10 @@
package com.audiobookshelf.app.managers package com.audiobookshelf.app.managers
import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.StatFs
import android.util.Log import android.util.Log
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.device.FolderScanner import com.audiobookshelf.app.device.FolderScanner
import com.audiobookshelf.app.models.DownloadItem import com.audiobookshelf.app.models.DownloadItem
@@ -14,6 +15,7 @@ import com.getcapacitor.JSObject
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlin.math.max
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -22,27 +24,36 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.Call 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( class DownloadItemManager(
private val folderScanner: FolderScanner, private val folderScanner: FolderScanner,
private val mainActivity: MainActivity, private val context: Context,
private val clientEventEmitter: DownloadEventEmitter private var clientEventEmitter: DownloadEventEmitter
) { ) {
private val tag = "DownloadItemManager" private val tag = "DownloadItemManager"
private val maxSimultaneousDownloads = 3
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val activeCalls = ConcurrentHashMap<String, Call>() 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 var watcherRunning = false
private val jacksonMapper = private val jacksonMapper =
jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
var downloadItemQueue: MutableList<DownloadItem> = mutableListOf() var downloadItemQueue: MutableList<DownloadItem> = mutableListOf()
private set
var currentDownloadItemParts: MutableList<DownloadItemPart> = mutableListOf() var currentDownloadItemParts: MutableList<DownloadItemPart> = mutableListOf()
private set
interface DownloadEventEmitter { interface DownloadEventEmitter {
fun onDownloadItem(downloadItem: DownloadItem) fun onDownloadItem(downloadItem: DownloadItem)
fun onDownloadItemPartUpdate(downloadItemPart: DownloadItemPart) fun onDownloadItemPartUpdate(downloadItemPart: DownloadItemPart)
fun onDownloadItemComplete(jsobj: JSObject) fun onDownloadItemComplete(jsobj: JSObject)
fun onQueueChanged(hasWork: Boolean)
} }
interface InternalProgressCallback { interface InternalProgressCallback {
@@ -55,49 +66,129 @@ class DownloadItemManager(
} }
@Synchronized @Synchronized
fun addDownloadItem(downloadItem: DownloadItem) { fun setEventEmitter(eventEmitter: DownloadEventEmitter) {
DeviceManager.dbManager.saveDownloadItem(downloadItem) clientEventEmitter = eventEmitter
downloadItemQueue.add(downloadItem) downloadItemQueue.forEach(clientEventEmitter::onDownloadItem)
clientEventEmitter.onDownloadItem(downloadItem) notifyQueueChanged()
checkUpdateDownloadQueue()
} }
@Synchronized @Synchronized
private fun checkUpdateDownloadQueue() { fun restoreQueue() {
for (downloadItem in downloadItemQueue.toList()) { if (downloadItemQueue.isNotEmpty()) return
val availableSlots = maxSimultaneousDownloads - currentDownloadItemParts.size DeviceManager.dbManager.getDownloadItems().forEach { item ->
if (availableSlots <= 0) break if (item.isDownloadFinished) {
downloadItem.getNextDownloadItemParts(availableSlots).forEach(::startDownload) 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) val stagingFile = File(part.destinationPath)
stagingFile.parentFile?.mkdirs() stagingFile.parentFile?.mkdirs()
part.downloadId = APP_MANAGED_DOWNLOAD_ID part.downloadId = APP_MANAGED_DOWNLOAD_ID
part.waitingForSpace = false
part.lastUpdateTime = System.currentTimeMillis() part.lastUpdateTime = System.currentTimeMillis()
currentDownloadItemParts.add(part) currentDownloadItemParts.add(part)
val callback = persist(item, force = true)
object : InternalProgressCallback { activeCalls[part.id] =
InternalDownloadManager(stagingFile, part.fileSize, object : InternalProgressCallback {
override fun onProgress(totalBytesWritten: Long, progress: Long) { override fun onProgress(totalBytesWritten: Long, progress: Long) {
synchronized(this@DownloadItemManager) { synchronized(this@DownloadItemManager) {
if (part !in currentDownloadItemParts) return
part.bytesDownloaded = totalBytesWritten part.bytesDownloaded = totalBytesWritten
part.progress = progress part.progress = progress
part.lastUpdateTime = System.currentTimeMillis() part.lastUpdateTime = System.currentTimeMillis()
persist(item)
} }
} }
override fun onComplete(failed: Boolean) { override fun onComplete(failed: Boolean) {
synchronized(this@DownloadItemManager) { synchronized(this@DownloadItemManager) {
if (part !in currentDownloadItemParts) return
part.failed = failed part.failed = failed
part.completed = true part.completed = !failed
part.lastUpdateTime = System.currentTimeMillis() part.lastUpdateTime = System.currentTimeMillis()
activeCalls.remove(part.id) activeCalls.remove(part.id)
persist(item, force = true)
} }
} }
} }, { hasAvailableSpace(part) }).download(serverUrl(item, part))
activeCalls[part.id] = InternalDownloadManager(stagingFile, part.fileSize, callback).download(part.serverUrl)
} }
@Synchronized @Synchronized
@@ -107,101 +198,223 @@ class DownloadItemManager(
scope.launch { scope.launch {
while (true) { while (true) {
val activeParts = synchronized(this@DownloadItemManager) { currentDownloadItemParts.toList() } val activeParts = synchronized(this@DownloadItemManager) { currentDownloadItemParts.toList() }
if (activeParts.isEmpty()) break
activeParts.forEach(::handlePartUpdate) activeParts.forEach(::handlePartUpdate)
synchronized(this@DownloadItemManager) {
checkUpdateDownloadQueue()
if (downloadItemQueue.isEmpty()) {
watcherRunning = false
notifyQueueChanged()
return@launch
}
}
delay(WATCH_INTERVAL_MS) delay(WATCH_INTERVAL_MS)
synchronized(this@DownloadItemManager) { checkUpdateDownloadQueue() }
} }
synchronized(this@DownloadItemManager) { watcherRunning = false }
} }
} }
private fun handlePartUpdate(part: DownloadItemPart) { private fun handlePartUpdate(part: DownloadItemPart) {
clientEventEmitter.onDownloadItemPartUpdate(part) 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 val lastUpdate = part.lastUpdateTime ?: return
if (System.currentTimeMillis() - lastUpdate > STALL_TIMEOUT_MS) { 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() activeCalls.remove(part.id)?.cancel()
synchronized(this) { failOrRetry(item, part, "Download stalled")
part.failed = true
part.completed = true
}
} }
return return
} }
val item = synchronized(this) { downloadItemQueue.find { it.id == part.downloadItemId } }
if (item == null) {
removeActivePart(part)
return
}
if (part.failed) { if (part.failed) {
removeActivePart(part) failOrRetry(item, part, "Transfer failed")
return return
} }
if (part.isInternalStorage) finalizeInternalFile(item, part) else moveDownloadedFile(item, part) 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) { private fun finalizeInternalFile(item: DownloadItem, part: DownloadItemPart) {
if (part.moved || part.isMoving) return if (part.moved || part.isMoving) return
part.isMoving = true part.isMoving = true
val stagingFile = File(part.destinationPath) val stagingFile = File(part.destinationPath)
val finalFile = File(part.finalDestinationPath) val finalFile = File(part.finalDestinationPath)
finalFile.parentFile?.mkdirs() finalFile.parentFile?.mkdirs()
if (finalFile.exists() && !finalFile.delete()) { val backup = File(finalFile.parentFile, ".${finalFile.name}.abs-backup")
failFinalization(item, part, "Could not replace existing internal file") try {
return 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) { private fun moveDownloadedFile(item: DownloadItem, part: DownloadItemPart) {
if (part.moved || part.isMoving) return if (part.moved || part.isMoving) return
val destinationRoot = DocumentFile.fromTreeUri(mainActivity, Uri.parse(part.localFolderUrl)) val root = DocumentFile.fromTreeUri(context, Uri.parse(part.localFolderUrl))
if (destinationRoot == null) { ?: return failFinalization(item, part, "Could not resolve SAF destination")
failFinalization(item, part, "Could not resolve SAF destination")
return
}
part.isMoving = true part.isMoving = true
persist(item, force = true)
scope.launch { scope.launch {
try { try {
val destinationFolder = getOrCreateFolder(destinationRoot, part.finalDestinationSubfolder) if (!hasAvailableSpace(part)) throw IllegalStateException("Insufficient storage for SAF copy")
?: throw IllegalStateException("Could not create SAF destination folder") val folderKey = "${root.uri}/${part.finalDestinationSubfolder}"
destinationFolder.findFile(part.filename)?.let { existing -> val folderLock = safFolderLocks.computeIfAbsent(folderKey) { Any() }
if (!existing.delete()) throw IllegalStateException("Could not replace ${part.filename}") 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) if (temporary.length() != staging.length()) throw IllegalStateException("SAF copy size mismatch")
?: throw IllegalStateException("Could not create ${part.filename}") val existing = folder.findFile(part.filename)
val stagingFile = File(part.destinationPath) if (existing != null && !existing.delete()) throw IllegalStateException("Could not replace existing file")
FileInputStream(stagingFile).use { input -> if (!temporary.renameTo(part.filename)) throw IllegalStateException("Could not finalize SAF temporary file")
mainActivity.contentResolver.openOutputStream(destinationFile.uri, "w")?.use { output -> val destination = folder.findFile(part.filename)
input.copyTo(output) ?: throw IllegalStateException("Could not reopen finalized SAF file")
} ?: throw IllegalStateException("Could not open SAF output stream") if (destination.length() != staging.length()) throw IllegalStateException("SAF final size mismatch")
} if (!staging.delete()) Log.w(tag, "Could not remove staging file ${staging.name}")
if (destinationFile.length() != stagingFile.length()) { part.completedDestinationUri = destination.uri.toString()
destinationFile.delete() completePart(item, part)
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)
} catch (e: Exception) { } catch (e: Exception) {
failFinalization(item, part, "SAF copy failed: ${e.message}") 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? { private fun getOrCreateFolder(root: DocumentFile, relativePath: String): DocumentFile? {
var current = root var current = root
relativePath.split('/').filter { it.isNotBlank() }.forEach { segment -> relativePath.split('/').filter { it.isNotBlank() }.forEach { segment ->
@@ -211,62 +424,30 @@ class DownloadItemManager(
return current return current
} }
private fun mimeTypeFor(part: DownloadItemPart): String { private fun mimeTypeFor(part: DownloadItemPart): String =
return part.audioTrack?.mimeType part.audioTrack?.mimeType ?: when (part.ebookFile?.ebookFormat?.lowercase()) {
?: when (part.ebookFile?.ebookFormat?.lowercase()) { "epub" -> "application/epub+zip"
"epub" -> "application/epub+zip" "pdf" -> "application/pdf"
"pdf" -> "application/pdf" else -> "image/jpeg"
else -> "image/jpeg" }
}
}
private fun failFinalization(item: DownloadItem, part: DownloadItemPart, message: String) { private fun serverUrl(item: DownloadItem, part: DownloadItemPart): String {
Log.e(tag, message) val token = DeviceManager.deviceData.serverConnectionConfigs
part.failed = true .find { it.id == item.serverConnectionConfigId }?.token ?: DeviceManager.token
part.isMoving = false var url = "${item.serverAddress}${part.serverPath}?token=$token"
part.completed = true if (part.serverPath.endsWith("/cover")) url += "&raw=1"
removeActivePart(part) return url
}
@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 companion object { private companion object {
const val APP_MANAGED_DOWNLOAD_ID = -1L 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 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
} }
} }
@@ -15,16 +15,10 @@ import okhttp3.Response
class InternalDownloadManager( class InternalDownloadManager(
private val destinationFile: File, private val destinationFile: File,
private val expectedSize: Long, private val expectedSize: Long,
private val progressCallback: DownloadItemManager.InternalProgressCallback private val progressCallback: DownloadItemManager.InternalProgressCallback,
private val hasAvailableSpace: () -> Boolean
) { ) {
private val tag = "InternalDownloadManager" 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 * 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. * 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) { override fun onResponse(call: Call, response: Response) {
response.use { response.use {
try { 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) val append = existingBytes > 0L && response.code == 206 && hasExpectedRange(response, existingBytes)
if (existingBytes > 0L && !append && response.code != 200) { if (existingBytes > 0L && !append && response.code != 200) {
Log.e(tag, "Invalid resume response ${response.code} for offset $existingBytes") Log.e(tag, "Invalid resume response ${response.code} for offset $existingBytes")
@@ -77,6 +76,7 @@ class InternalDownloadManager(
while (true) { while (true) {
val read = input.read(buffer) val read = input.read(buffer)
if (read < 0) break if (read < 0) break
if (!hasAvailableSpace()) throw IOException("Download paused to preserve free storage")
output.write(buffer, 0, read) output.write(buffer, 0, read)
totalBytes += read totalBytes += read
val progress = if (totalLength > 0L) (totalBytes * 100L) / totalLength else 0L val progress = if (totalLength > 0L) (totalBytes * 100L) / totalLength else 0L
@@ -104,10 +104,19 @@ class InternalDownloadManager(
private fun hasExpectedRange(response: Response, offset: Long): Boolean { private fun hasExpectedRange(response: Response, offset: Long): Boolean {
val range = response.header("Content-Range") ?: return false 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 { private companion object {
const val CHUNK_SIZE = 8 * 1024 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 if (limit == 0) return itemParts
for (it in downloadItemParts) { for (it in downloadItemParts) {
if (!it.completed && it.downloadId == null) { if (!it.completed && !it.failed && it.downloadId == null) {
itemParts.add(it) itemParts.add(it)
if (itemParts.size >= limit) break if (itemParts.size >= limit) break
} }
@@ -16,7 +16,7 @@ data class DownloadItemPart(
val filename: String, val filename: String,
val fileSize: Long, val fileSize: Long,
/** App-owned staging location. This is intentionally a String so it survives process storage. */ /** 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 finalDestinationPath:String,
val serverPath: String, val serverPath: String,
val localFolderName: String, val localFolderName: String,
@@ -33,12 +33,16 @@ data class DownloadItemPart(
@JsonIgnore val destinationUri: Uri, @JsonIgnore val destinationUri: Uri,
@JsonIgnore val finalDestinationUri: Uri, @JsonIgnore val finalDestinationUri: Uri,
/** Final SAF document returned by the provider after a successful move. */ /** 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?, @JsonIgnore var completedDestinationUri: String?,
val finalDestinationSubfolder: String, val finalDestinationSubfolder: String,
var downloadId: Long?, var downloadId: Long?,
var lastUpdateTime: Long?, @JsonIgnore var lastUpdateTime: Long?,
var progress: 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 { 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 { 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-") val isInternalStorage get() = localFolderId.startsWith("internal-")
@get:JsonIgnore @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.models.DownloadItemPart
import com.audiobookshelf.app.server.ApiHandler import com.audiobookshelf.app.server.ApiHandler
import com.audiobookshelf.app.managers.DownloadItemManager import com.audiobookshelf.app.managers.DownloadItemManager
import com.audiobookshelf.app.services.DownloadServiceHost
import com.fasterxml.jackson.core.json.JsonReadFeature import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.getcapacitor.JSObject import com.getcapacitor.JSObject
@@ -39,20 +40,36 @@ class AbsDownloader : Plugin() {
override fun onDownloadItemComplete(jsobj:JSObject) { override fun onDownloadItemComplete(jsobj:JSObject) {
notifyListeners("onItemDownloadComplete", jsobj) notifyListeners("onItemDownloadComplete", jsobj)
} }
override fun onQueueChanged(hasWork: Boolean) = Unit
}) })
override fun load() { override fun load() {
mainActivity = (activity as MainActivity) mainActivity = (activity as MainActivity)
folderScanner = FolderScanner(mainActivity) folderScanner = FolderScanner(mainActivity)
apiHandler = ApiHandler(mainActivity) apiHandler = ApiHandler(mainActivity)
downloadItemManager = DownloadItemManager(folderScanner, mainActivity, clientEventEmitter) downloadItemManager = DownloadServiceHost.ensure(mainActivity)
DownloadServiceHost.attachBridge(mainActivity, clientEventEmitter)
} }
override fun handleOnDestroy() { override fun handleOnDestroy() {
if (::downloadItemManager.isInitialized) downloadItemManager.destroy() DownloadServiceHost.detachBridge()
super.handleOnDestroy() 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 @PluginMethod
fun downloadLibraryItem(call: PluginCall) { fun downloadLibraryItem(call: PluginCall) {
val libraryItemId = call.data.getString("libraryItemId").toString() val libraryItemId = call.data.getString("libraryItemId").toString()
@@ -163,11 +180,6 @@ class AbsDownloader : Plugin() {
val finalDestinationFile = File("$itemFolderPath/$destinationFilename") val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
val destinationFile = File("$tempFolderPath/$destinationFilename.part") 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) val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,ebookFile,null,null)
downloadItem.downloadItemParts.add(downloadItemPart) downloadItem.downloadItemParts.add(downloadItemPart)
} }
@@ -187,11 +199,6 @@ class AbsDownloader : Plugin() {
val finalDestinationFile = File("$itemFolderPath/$destinationFilename") val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
val destinationFile = File("$tempFolderPath/$destinationFilename.part") 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) val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,audioTrack,null)
downloadItem.downloadItemParts.add(downloadItemPart) downloadItem.downloadItemParts.add(downloadItemPart)
} }
@@ -207,16 +214,11 @@ class AbsDownloader : Plugin() {
val destinationFile = File("$tempFolderPath/$destinationFilename.part") val destinationFile = File("$tempFolderPath/$destinationFilename.part")
val finalDestinationFile = File("$itemFolderPath/$destinationFilename") 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) val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null,null)
downloadItem.downloadItemParts.add(downloadItemPart) downloadItem.downloadItemParts.add(downloadItemPart)
} }
downloadItemManager.addDownloadItem(downloadItem) DownloadServiceHost.enqueue(mainActivity, downloadItem)
} }
} else { } else {
// Podcast episode download // Podcast episode download
@@ -237,11 +239,6 @@ class AbsDownloader : Plugin() {
var destinationFile = File("$tempFolderPath/$destinationFilename.part") var destinationFile = File("$tempFolderPath/$destinationFilename.part")
var finalDestinationFile = File("$itemFolderPath/$destinationFilename") 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) var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,audioTrack,episode)
downloadItem.downloadItemParts.add(downloadItemPart) downloadItem.downloadItemParts.add(downloadItemPart)
@@ -255,15 +252,11 @@ class AbsDownloader : Plugin() {
destinationFile = File("$tempFolderPath/$destinationFilename.part") destinationFile = File("$tempFolderPath/$destinationFilename.part")
finalDestinationFile = File("$itemFolderPath/$destinationFilename") finalDestinationFile = File("$itemFolderPath/$destinationFilename")
if (finalDestinationFile.exists()) { downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,null)
Log.d(tag, "Podcast cover already exists - not downloading cover again") downloadItem.downloadItemParts.add(downloadItemPart)
} else {
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
}
}