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
+ }
+}