From 6aeb31b59116cf08772c0549421f6fb77fda7f77 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 18 Jul 2026 22:56:12 -0700 Subject: [PATCH] Add support for background download and resumption --- android/app/src/main/AndroidManifest.xml | 7 + .../app/managers/DownloadItemManager.kt | 435 +++++++++++++----- .../app/managers/InternalDownloadManager.kt | 27 +- .../audiobookshelf/app/models/DownloadItem.kt | 2 +- .../app/models/DownloadItemPart.kt | 17 +- .../app/plugins/AbsDownloader.kt | 53 +-- .../app/services/DownloadService.kt | 88 ++++ .../app/services/DownloadServiceHost.kt | 104 +++++ 8 files changed, 562 insertions(+), 171 deletions(-) create mode 100644 android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 67a6f3c5..1ecfd2bc 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + + + () + /** DocumentsProvider does not make concurrent createDirectory/findFile calls atomic. */ + private val safFolderLocks = ConcurrentHashMap() + private val reservations = mutableMapOf() + private val lastPersistTime = mutableMapOf() private var watcherRunning = false private val jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) var downloadItemQueue: MutableList = mutableListOf() + private set var currentDownloadItemParts: MutableList = 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 } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt index 128d41a6..2854fc2a 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt @@ -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() } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt index 95ec320b..e23ca2d9 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt @@ -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 } diff --git a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt index a60a24cd..0a6081ae 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt @@ -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 + } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt index 3dde53d2..94f65bdd 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt @@ -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) } } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt new file mode 100644 index 00000000..ebd938d0 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt @@ -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) + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt new file mode 100644 index 00000000..ce4882f7 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt @@ -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()) + + @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 + } +}