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 -->
<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"
@@ -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
}
}
@@ -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
}
}