Merge pull request #2002 from nichwall/android-download-followup

Android download followup
This commit is contained in:
advplyr
2026-09-06 18:22:08 -05:00
committed by GitHub
11 changed files with 706 additions and 317 deletions
@@ -3,12 +3,14 @@ package com.audiobookshelf.app.managers
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.StatFs import android.os.StatFs
import android.util.Log
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.anggrayudi.storage.file.fullName
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.device.FolderScanner import com.audiobookshelf.app.device.FolderScanner
import com.audiobookshelf.app.models.DownloadItem import com.audiobookshelf.app.models.DownloadItem
import com.audiobookshelf.app.models.DownloadItemPart import com.audiobookshelf.app.models.DownloadItemPart
import com.audiobookshelf.app.plugins.AbsLogger
import com.audiobookshelf.app.server.ApiHandler
import com.fasterxml.jackson.core.json.JsonReadFeature import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.getcapacitor.JSObject import com.getcapacitor.JSObject
@@ -22,7 +24,6 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.Call
/** Manages the process-owned queue for app-managed downloads. */ /** Manages the process-owned queue for app-managed downloads. */
class DownloadItemManager( class DownloadItemManager(
@@ -32,10 +33,14 @@ class DownloadItemManager(
) { ) {
private val tag = "DownloadItemManager" private val tag = "DownloadItemManager"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val activeCalls = ConcurrentHashMap<String, Call>() private val activeCalls = ConcurrentHashMap<String, InternalDownloadManager.DownloadHandle>()
private val safFolderLocks = ConcurrentHashMap<String, Any>() private val safFolderLocks = ConcurrentHashMap<String, Any>()
private val scanLocks = ConcurrentHashMap<String, Any>()
private val reservations = mutableMapOf<String, Long>() private val reservations = mutableMapOf<String, Long>()
private val lastPersistTime = mutableMapOf<String, Long>() private val lastPersistTime = mutableMapOf<String, Long>()
private val finalizingItems = mutableSetOf<String>()
private val refreshingServerIds = mutableSetOf<String>()
private val apiHandler = ApiHandler(context)
private var watcherRunning = false private var watcherRunning = false
private val jacksonMapper = private val jacksonMapper =
jacksonObjectMapper() jacksonObjectMapper()
@@ -54,12 +59,10 @@ class DownloadItemManager(
} }
interface InternalProgressCallback { interface InternalProgressCallback {
fun onSizeResolved(totalBytes: Long)
fun onProgress(totalBytesWritten: Long, progress: Long) fun onProgress(totalBytesWritten: Long, progress: Long)
fun onComplete(failed: Boolean) fun onComplete(failed: Boolean)
} fun onAuthError()
init {
IncompleteDownloadCleanup.cleanupExpired(context)
} }
@Synchronized @Synchronized
@@ -73,26 +76,37 @@ class DownloadItemManager(
fun restoreQueue() { fun restoreQueue() {
if (downloadItemQueue.isNotEmpty()) return if (downloadItemQueue.isNotEmpty()) return
DeviceManager.dbManager.getDownloadItems().forEach { item -> DeviceManager.dbManager.getDownloadItems().forEach { item ->
item.downloadItemParts.filter { it.moved }.forEach { part ->
if (!finalizedFileExists(part)) {
AbsLogger.error(tag, "Finalized file is missing; resetting ${part.filename}")
part.moved = false
part.completed = false
part.completedDestinationUri = null
part.downloadId = null
part.reusedExistingFile = false
}
}
if (item.isDownloadFinished) { if (item.isDownloadFinished) {
downloadItemQueue.add(item) downloadItemQueue.add(item)
checkDownloadItemFinished(item) checkDownloadItemFinished(item)
return@forEach return@forEach
} }
var resetFailed = false
item.downloadItemParts.forEach { part -> item.downloadItemParts.forEach { part ->
if (part.moved) return@forEach if (part.moved) return@forEach
if (item.terminalFailureAt != null && part.failed) return@forEach if (!resetPartForFreshDownload(part)) resetFailed = true
part.downloadId = null }
part.isMoving = false if (resetFailed) {
part.failed = false item.terminalFailureAt = item.terminalFailureAt ?: System.currentTimeMillis()
part.completed = false item.stagingCleanupAt = null
part.waitingForSpace = false }
part.bytesDownloaded = File(part.destinationPath).takeIf(File::exists)?.length() ?: 0L if (item.terminalFailureAt != null) {
item.downloadItemParts.filter { !it.moved }.forEach { it.failed = true }
} }
downloadItemQueue.add(item) downloadItemQueue.add(item)
if (item.terminalFailureAt != null) IncompleteDownloadCleanup.schedule(context, item) if (item.terminalFailureAt != null) IncompleteDownloadCleanup.schedule(context, item)
clientEventEmitter.onDownloadItem(item) clientEventEmitter.onDownloadItem(item)
} }
checkUpdateDownloadQueue()
notifyQueueChanged() notifyQueueChanged()
} }
@@ -100,39 +114,68 @@ class DownloadItemManager(
fun addDownloadItem(downloadItem: DownloadItem) { fun addDownloadItem(downloadItem: DownloadItem) {
val existingItem = downloadItemQueue.find { it.id == downloadItem.id } val existingItem = downloadItemQueue.find { it.id == downloadItem.id }
if (existingItem != null) { if (existingItem != null) {
if (existingItem.terminalFailureAt != null) {
retryDownloadItem(existingItem)
checkUpdateDownloadQueue()
notifyQueueChanged()
}
return return
} }
persist(downloadItem, force = true) persist(downloadItem, force = true)
downloadItemQueue.add(downloadItem) downloadItemQueue.add(downloadItem)
clientEventEmitter.onDownloadItem(downloadItem) clientEventEmitter.onDownloadItem(downloadItem)
notifyQueueChanged()
}
@Synchronized
fun retryDownloadItem(downloadItemId: String): Boolean {
val item = downloadItemQueue.find { it.id == downloadItemId } ?: return false
if (item.downloadItemParts.any { it in currentDownloadItemParts }) return false
if (item.isDownloadFinished) return false
synchronized(IncompleteDownloadCleanup) {
var resetFailed = false
item.downloadItemParts.filter { !it.moved }.forEach { part ->
if (!resetPartForFreshDownload(part)) resetFailed = true
}
if (resetFailed) {
item.downloadItemParts.filter { !it.moved }.forEach { it.failed = true }
persist(item, force = true)
return false
}
item.terminalFailureAt = null
item.stagingCleanupAt = null
IncompleteDownloadCleanup.cancel(context, item.id)
persist(item, force = true)
}
clientEventEmitter.onDownloadItem(item)
notifyQueueChanged()
return true
}
@Synchronized
fun resumeWork() {
checkUpdateDownloadQueue() checkUpdateDownloadQueue()
notifyQueueChanged() notifyQueueChanged()
} }
private fun retryDownloadItem(item: DownloadItem) {
item.terminalFailureAt = null
IncompleteDownloadCleanup.cancel(context, item.id)
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)
}
@Synchronized @Synchronized
fun cancelAll() { fun cancelAll() {
activeCalls.values.forEach(Call::cancel) activeCalls.values.forEach(InternalDownloadManager.DownloadHandle::cancel)
activeCalls.clear() activeCalls.clear()
downloadItemQueue.forEach { item -> downloadItemQueue.forEach { item ->
item.downloadItemParts.forEach { part -> File(part.destinationPath).delete() } item.downloadItemParts.forEach { part ->
File(part.destinationPath).delete()
if (part.moved && !part.reusedExistingFile && part.isInternalStorage) {
File(part.finalDestinationPath).delete()
} else if (part.moved && !part.reusedExistingFile) {
part.completedDestinationUri?.let { uri ->
try {
DocumentFile.fromSingleUri(context, Uri.parse(uri))?.delete()
} catch (e: Exception) {
AbsLogger.error(
tag,
"Could not delete cancelled SAF file ${part.filename}: ${e.message}"
)
}
}
}
}
IncompleteDownloadCleanup.cancel(context, item.id)
DeviceManager.dbManager.removeDownloadItem(item.id) DeviceManager.dbManager.removeDownloadItem(item.id)
} }
currentDownloadItemParts.clear() currentDownloadItemParts.clear()
@@ -143,16 +186,34 @@ class DownloadItemManager(
@Synchronized @Synchronized
fun hasWork(): Boolean = fun hasWork(): Boolean =
downloadItemQueue.any { item -> finalizingItems.isNotEmpty() ||
item.downloadItemParts.any { part -> downloadItemQueue.any { item ->
(!part.completed && !part.failed) || part.isMoving item.downloadItemParts.any { part ->
} (!part.moved && !part.failed) || part.isMoving
} }
}
@Synchronized @Synchronized
private fun checkUpdateDownloadQueue() { private fun checkUpdateDownloadQueue() {
downloadItemQueue.toList().forEach { item -> downloadItemQueue.toList().forEach { item ->
val slots = MAX_SIMULTANEOUS_DOWNLOADS - currentDownloadItemParts.size var slots = MAX_SIMULTANEOUS_DOWNLOADS - currentDownloadItemParts.size
if (slots <= 0) return@forEach
item.downloadItemParts
.filter { part ->
part.completed &&
!part.moved &&
!part.failed &&
!part.isMoving &&
part !in currentDownloadItemParts &&
File(part.destinationPath).exists() &&
!hasActiveDestinationConflict(part)
}
.take(slots)
.forEach { part ->
currentDownloadItemParts.add(part)
part.downloadId = APP_MANAGED_DOWNLOAD_ID
}
slots = MAX_SIMULTANEOUS_DOWNLOADS - currentDownloadItemParts.size
if (slots <= 0) return@forEach if (slots <= 0) return@forEach
item.getNextDownloadItemParts(slots).forEach { part -> item.getNextDownloadItemParts(slots).forEach { part ->
val existingFile = findSharedStorageFile(part) val existingFile = findSharedStorageFile(part)
@@ -160,9 +221,17 @@ class DownloadItemManager(
part.bytesDownloaded = existingFile.length() part.bytesDownloaded = existingFile.length()
part.progress = 100L part.progress = 100L
part.completedDestinationUri = existingFile.uri.toString() part.completedDestinationUri = existingFile.uri.toString()
part.reusedExistingFile = true
File(part.destinationPath).delete() File(part.destinationPath).delete()
completePart(item, part) completePart(item, part)
clientEventEmitter.onDownloadItemPartUpdate(part) clientEventEmitter.onDownloadItemPartUpdate(part)
return@forEach
}
if (completeFromExistingInternalCover(item, part)) return@forEach
if (hasActiveDestinationConflict(part)) {
leaveQueued(item, part)
} else if (part.fileSize <= 0L && currentDownloadItemParts.any { it.fileSize <= 0L }) {
leaveQueued(item, part)
} else if (tryReserve(part)) startDownload(item, part) } else if (tryReserve(part)) startDownload(item, part)
else { else {
part.waitingForSpace = true part.waitingForSpace = true
@@ -183,17 +252,33 @@ class DownloadItemManager(
part.lastUpdateTime = System.currentTimeMillis() part.lastUpdateTime = System.currentTimeMillis()
currentDownloadItemParts.add(part) currentDownloadItemParts.add(part)
persist(item, force = true) persist(item, force = true)
AbsLogger.info(tag, "Starting download for ${part.filename}")
val activeConfig = DeviceManager.serverConnectionConfig val activeConfig = DeviceManager.serverConnectionConfig
val token = val token =
if (activeConfig?.id == item.serverConnectionConfigId) activeConfig.token if (activeConfig?.id == item.serverConnectionConfigId) activeConfig.token
else else
DeviceManager.getServerConnectionConfig(item.serverConnectionConfigId)?.token DeviceManager.getServerConnectionConfig(item.serverConnectionConfigId)?.token
?: DeviceManager.token ?: DeviceManager.token
activeCalls[part.id] = val handle =
InternalDownloadManager( InternalDownloadManager(
stagingFile, stagingFile,
part.fileSize, part.fileSize,
object : InternalProgressCallback { object : InternalProgressCallback {
override fun onSizeResolved(totalBytes: Long) {
synchronized(this@DownloadItemManager) {
if (part !in currentDownloadItemParts || totalBytes < 0L) return
if (part.fileSize == totalBytes) return
AbsLogger.info(
tag,
"Using server size $totalBytes instead of metadata size ${part.fileSize} for ${part.filename}"
)
part.fileSize = totalBytes
part.lastUpdateTime = System.currentTimeMillis()
persist(item, force = true)
clientEventEmitter.onDownloadItemPartUpdate(part)
}
}
override fun onProgress(totalBytesWritten: Long, progress: Long) { override fun onProgress(totalBytesWritten: Long, progress: Long) {
synchronized(this@DownloadItemManager) { synchronized(this@DownloadItemManager) {
if (part !in currentDownloadItemParts) return if (part !in currentDownloadItemParts) return
@@ -214,10 +299,20 @@ class DownloadItemManager(
persist(item, force = true) persist(item, force = true)
} }
} }
override fun onAuthError() {
synchronized(this@DownloadItemManager) {
if (part !in currentDownloadItemParts) return
handleAuthError(item, part)
}
}
}, },
{ hasAvailableSpace(part) } { hasAvailableSpace(part) }
) )
.download(serverUrl(item, part), token) .download(serverUrl(item, part), token)
if (part in currentDownloadItemParts && !part.completed && !part.failed) {
activeCalls[part.id] = handle
}
} }
@Synchronized @Synchronized
@@ -253,7 +348,7 @@ class DownloadItemManager(
if (!part.completed && !part.failed) { if (!part.completed && !part.failed) {
val lastUpdate = part.lastUpdateTime ?: return val lastUpdate = part.lastUpdateTime ?: return
if (System.currentTimeMillis() - lastUpdate > STALL_TIMEOUT_MS) { if (System.currentTimeMillis() - lastUpdate > STALL_TIMEOUT_MS) {
Log.w(tag, "Download stalled: ${part.filename}") AbsLogger.error(tag, "Download stalled: ${part.filename}")
activeCalls.remove(part.id)?.cancel() activeCalls.remove(part.id)?.cancel()
failOrRetry(item, part, "Download stalled") failOrRetry(item, part, "Download stalled")
} }
@@ -272,14 +367,7 @@ class DownloadItemManager(
part.retryCount += 1 part.retryCount += 1
reservations.remove(part.destinationPath) reservations.remove(part.destinationPath)
if (part.retryCount > MAX_RETRIES) { if (part.retryCount > MAX_RETRIES) {
Log.e(tag, "$reason after $MAX_RETRIES retries: ${part.filename}") markTerminalFailure(item, part, "$reason after $MAX_RETRIES retries")
part.failed = true
part.completed = false
part.downloadId = null
item.terminalFailureAt = item.terminalFailureAt ?: System.currentTimeMillis()
persist(item, force = true)
IncompleteDownloadCleanup.schedule(context, item)
notifyQueueChanged()
return return
} }
part.failed = false part.failed = false
@@ -287,6 +375,94 @@ class DownloadItemManager(
part.downloadId = null part.downloadId = null
part.isMoving = false part.isMoving = false
persist(item, force = true) persist(item, force = true)
clientEventEmitter.onDownloadItemPartUpdate(part)
}
/** A 401 refreshes the token for this queued item's server without consuming transfer retries. */
@Synchronized
private fun handleAuthError(item: DownloadItem, part: DownloadItemPart) {
removeActivePart(part)
reservations.remove(part.destinationPath)
part.downloadId = null
part.isMoving = false
part.failed = false
part.completed = false
part.authRetryCount += 1
part.lastUpdateTime = System.currentTimeMillis()
if (part.authRetryCount > MAX_AUTH_RETRIES) {
markTerminalFailure(item, part, "Unauthorized after $MAX_AUTH_RETRIES token refresh attempts")
return
}
AbsLogger.info(
tag,
"Refreshing token after 401 for ${part.filename} (attempt ${part.authRetryCount})"
)
persist(item, force = true)
clientEventEmitter.onDownloadItemPartUpdate(part)
refreshTokenThenResume(item.serverConnectionConfigId)
}
private fun refreshTokenThenResume(serverConnectionConfigId: String) {
if (!refreshingServerIds.add(serverConnectionConfigId)) return
apiHandler.refreshAuthTokens(serverConnectionConfigId) { result ->
synchronized(this@DownloadItemManager) {
refreshingServerIds.remove(serverConnectionConfigId)
when (result) {
is ApiHandler.RefreshResult.Success -> {
AbsLogger.info(
tag,
"Token refresh succeeded; resuming downloads for $serverConnectionConfigId"
)
checkUpdateDownloadQueue()
}
ApiHandler.RefreshResult.Rejected -> failParkedAuthParts(serverConnectionConfigId)
// Parked parts are still queued, so MAX_AUTH_RETRIES bounds the reattempts.
ApiHandler.RefreshResult.Transient -> {
AbsLogger.info(
tag,
"Token refresh could not be completed; retrying downloads for $serverConnectionConfigId"
)
checkUpdateDownloadQueue()
}
}
}
}
}
@Synchronized
private fun failParkedAuthParts(serverConnectionConfigId: String) {
downloadItemQueue.toList().forEach { item ->
if (item.serverConnectionConfigId != serverConnectionConfigId) return@forEach
item.downloadItemParts
.filter {
it.authRetryCount > 0 &&
!it.completed &&
!it.failed &&
it.downloadId == null &&
it !in currentDownloadItemParts
}
.forEach { part ->
markTerminalFailure(item, part, "Unable to refresh download authorization")
}
}
}
@Synchronized
private fun markTerminalFailure(item: DownloadItem, part: DownloadItemPart, reason: String) {
AbsLogger.error(tag, "$reason: ${part.filename}")
removeActivePart(part)
reservations.remove(part.destinationPath)
part.failed = true
part.completed = false
part.downloadId = null
part.isMoving = false
item.terminalFailureAt = item.terminalFailureAt ?: System.currentTimeMillis()
item.stagingCleanupAt = null
persist(item, force = true)
IncompleteDownloadCleanup.schedule(context, item)
clientEventEmitter.onDownloadItemPartUpdate(part)
notifyQueueChanged()
} }
private fun finalizeInternalFile(item: DownloadItem, part: DownloadItemPart) { private fun finalizeInternalFile(item: DownloadItem, part: DownloadItemPart) {
@@ -305,6 +481,7 @@ class DownloadItemManager(
throw IllegalStateException("Could not finalize internal staging file") throw IllegalStateException("Could not finalize internal staging file")
} }
backup.delete() backup.delete()
AbsLogger.info(tag, "Move completed for ${part.filename} to ${finalFile.absolutePath}")
completePart(item, part) completePart(item, part)
} catch (e: Exception) { } catch (e: Exception) {
part.isMoving = false part.isMoving = false
@@ -341,18 +518,19 @@ class DownloadItemManager(
} }
if (temporary.length() != staging.length()) if (temporary.length() != staging.length())
throw IllegalStateException("SAF copy size mismatch") throw IllegalStateException("SAF copy size mismatch")
val existing = folder.findFile(part.filename) val existing = findDocumentByFilename(folder, part)
if (existing != null && !existing.delete()) if (existing != null && !existing.delete())
throw IllegalStateException("Could not replace existing file") throw IllegalStateException("Could not replace existing file")
if (!temporary.renameTo(part.filename)) if (!temporary.renameTo(part.filename))
throw IllegalStateException("Could not finalize SAF temporary file") throw IllegalStateException("Could not finalize SAF temporary file")
val destination = val destination =
folder.findFile(part.filename) findDocumentByFilename(folder, part)
?: throw IllegalStateException("Could not reopen finalized SAF file") ?: throw IllegalStateException("Could not reopen finalized SAF file")
if (destination.length() != staging.length()) if (destination.length() != staging.length())
throw IllegalStateException("SAF final size mismatch") throw IllegalStateException("SAF final size mismatch")
if (!staging.delete()) Log.w(tag, "Could not remove staging file ${staging.name}") if (!staging.delete()) AbsLogger.error(tag, "Could not remove staging file ${staging.name}")
part.completedDestinationUri = destination.uri.toString() part.completedDestinationUri = destination.uri.toString()
AbsLogger.info(tag, "Move completed for ${part.filename} to ${destination.uri}")
completePart(item, part) completePart(item, part)
} catch (e: Exception) { } catch (e: Exception) {
failFinalization(item, part, "SAF copy failed: ${e.message}") failFinalization(item, part, "SAF copy failed: ${e.message}")
@@ -362,7 +540,7 @@ class DownloadItemManager(
@Synchronized @Synchronized
private fun failFinalization(item: DownloadItem, part: DownloadItemPart, message: String) { private fun failFinalization(item: DownloadItem, part: DownloadItemPart, message: String) {
Log.e(tag, message) AbsLogger.error(tag, message)
part.isMoving = false part.isMoving = false
part.failed = true part.failed = true
failOrRetry(item, part, message) failOrRetry(item, part, message)
@@ -380,25 +558,39 @@ class DownloadItemManager(
checkDownloadItemFinished(item) checkDownloadItemFinished(item)
} }
@Synchronized
private fun checkDownloadItemFinished(item: DownloadItem) { private fun checkDownloadItemFinished(item: DownloadItem) {
if (!item.isDownloadFinished) return if (!item.isDownloadFinished || !finalizingItems.add(item.id)) return
IncompleteDownloadCleanup.cancel(context, item.id)
scope.launch { scope.launch {
folderScanner.scanDownloadItem(item) { scanResult -> try {
val event = val scanLock = scanLocks.computeIfAbsent(scanDestinationKey(item)) { Any() }
JSObject().apply { synchronized(scanLock) {
put("libraryItemId", item.id) folderScanner.scanDownloadItem(item) { scanResult ->
put("localFolderId", item.localFolder.id) val event =
scanResult?.localLibraryItem?.let { JSObject().apply {
put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(it))) put("libraryItemId", item.id)
} put("localFolderId", item.localFolder.id)
scanResult?.localMediaProgress?.let { scanResult?.localLibraryItem?.let {
put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(it))) put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(it)))
} }
} scanResult?.localMediaProgress?.let {
clientEventEmitter.onDownloadItemComplete(event) put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(it)))
}
}
clientEventEmitter.onDownloadItemComplete(event)
synchronized(this@DownloadItemManager) {
downloadItemQueue.remove(item)
DeviceManager.dbManager.removeDownloadItem(item.id)
}
}
}
} catch (e: Exception) {
// The files are already in place, so leave the item queued for restoreQueue to rescan.
AbsLogger.error(tag, "Could not finalize download item ${item.id}: ${e.message}")
} finally {
synchronized(this@DownloadItemManager) { synchronized(this@DownloadItemManager) {
downloadItemQueue.remove(item) finalizingItems.remove(item.id)
DeviceManager.dbManager.removeDownloadItem(item.id)
notifyQueueChanged() notifyQueueChanged()
} }
} }
@@ -406,7 +598,6 @@ class DownloadItemManager(
} }
private fun tryReserve(part: DownloadItemPart): Boolean { private fun tryReserve(part: DownloadItemPart): Boolean {
if (part.fileSize <= 0L && currentDownloadItemParts.any { it.fileSize <= 0L }) return false
val staging = File(part.destinationPath) val staging = File(part.destinationPath)
staging.parentFile?.mkdirs() staging.parentFile?.mkdirs()
val expectedSize = if (part.fileSize > 0L) part.fileSize else UNKNOWN_PART_RESERVATION_BYTES val expectedSize = if (part.fileSize > 0L) part.fileSize else UNKNOWN_PART_RESERVATION_BYTES
@@ -457,7 +648,7 @@ class DownloadItemManager(
} }
fun destroy() { fun destroy() {
activeCalls.values.forEach(Call::cancel) activeCalls.values.forEach(InternalDownloadManager.DownloadHandle::cancel)
activeCalls.clear() activeCalls.clear()
scope.cancel() scope.cancel()
} }
@@ -479,13 +670,103 @@ class DownloadItemManager(
if (segment == "." || segment == "..") return null if (segment == "." || segment == "..") return null
folder = folder.findFile(segment) ?: return null folder = folder.findFile(segment) ?: return null
} }
val file = folder.findFile(part.filename) ?: return null val file = findDocumentByFilename(folder, part) ?: return null
if (!file.isFile) return null if (!file.isFile) return null
if (part.fileSize > 0L && file.length() != part.fileSize) return null if (part.fileSize > 0L && file.length() != part.fileSize) return null
if (part.fileSize <= 0L && file.length() <= 0L) return null if (part.fileSize <= 0L && file.length() <= 0L) return null
return file return file
} }
private fun completeFromExistingInternalCover(
item: DownloadItem,
part: DownloadItemPart
): Boolean {
if (!part.isInternalStorage || !part.serverPath.endsWith("/cover")) return false
val file = File(part.finalDestinationPath)
if (!file.isFile || file.length() <= 0L) return false
if (part.fileSize > 0L && file.length() != part.fileSize) return false
part.bytesDownloaded = file.length()
part.progress = 100L
part.reusedExistingFile = true
AbsLogger.info(tag, "Reusing existing cover ${part.filename}")
File(part.destinationPath).delete()
completePart(item, part)
clientEventEmitter.onDownloadItemPartUpdate(part)
return true
}
private fun hasActiveDestinationConflict(part: DownloadItemPart): Boolean =
currentDownloadItemParts.any { activePart ->
activePart !== part &&
activePart.localFolderId == part.localFolderId &&
activePart.finalDestinationPath == part.finalDestinationPath
}
private fun leaveQueued(item: DownloadItem, part: DownloadItemPart) {
if (!part.waitingForSpace) return
part.waitingForSpace = false
part.downloadId = null
persist(item)
clientEventEmitter.onDownloadItemPartUpdate(part)
}
private fun scanDestinationKey(item: DownloadItem): String =
"${item.localFolder.id}:${item.itemFolderPath}"
private fun finalizedFileExists(part: DownloadItemPart): Boolean {
if (part.isInternalStorage) {
val file = File(part.finalDestinationPath)
return file.isFile &&
if (part.fileSize > 0L) file.length() == part.fileSize else file.length() > 0L
}
part.completedDestinationUri?.let { uri ->
try {
val file = DocumentFile.fromSingleUri(context, Uri.parse(uri))
if (file?.isFile == true && (part.fileSize <= 0L || file.length() == part.fileSize))
return true
} catch (e: Exception) {
AbsLogger.error(tag, "Could not validate SAF file ${part.filename}: ${e.message}")
}
}
return findSharedStorageFile(part) != null
}
/** Resets an unmoved part when recovery crosses a service-session boundary. */
private fun resetPartForFreshDownload(part: DownloadItemPart): Boolean {
val stagingFile = File(part.destinationPath)
if (stagingFile.exists() && !stagingFile.delete()) {
AbsLogger.error(tag, "Could not delete staging file ${part.filename}")
part.failed = true
return false
}
part.completed = false
part.bytesDownloaded = 0L
part.progress = 0L
part.failed = false
part.isMoving = false
part.downloadId = null
part.retryCount = 0
part.authRetryCount = 0
part.waitingForSpace = false
part.reusedExistingFile = false
return true
}
private fun findDocumentByFilename(folder: DocumentFile, part: DownloadItemPart): DocumentFile? {
folder.findFile(part.filename)?.let {
return it
}
val expectedBaseName = part.filename.substringBeforeLast('.')
return folder.listFiles().firstOrNull { document ->
document.name == part.filename ||
document.fullName == part.filename ||
(part.audioTrack != null &&
document.isFile &&
((document.name ?: "").substringBeforeLast('.') == expectedBaseName ||
document.fullName.substringBeforeLast('.') == expectedBaseName))
}
}
private fun mimeTypeFor(part: DownloadItemPart): String = private fun mimeTypeFor(part: DownloadItemPart): String =
part.audioTrack?.mimeType part.audioTrack?.mimeType
?: when (part.ebookFile?.ebookFormat?.lowercase()) { ?: when (part.ebookFile?.ebookFormat?.lowercase()) {
@@ -505,6 +786,7 @@ class DownloadItemManager(
const val WATCH_INTERVAL_MS = 1_000L const val WATCH_INTERVAL_MS = 1_000L
const val STALL_TIMEOUT_MS = 60_000L const val STALL_TIMEOUT_MS = 60_000L
const val MAX_RETRIES = 5 const val MAX_RETRIES = 5
const val MAX_AUTH_RETRIES = 2
const val PERSIST_INTERVAL_MS = 2_000L const val PERSIST_INTERVAL_MS = 2_000L
const val MIN_FREE_SPACE_BYTES = 100L * 1024L * 1024L const val MIN_FREE_SPACE_BYTES = 100L * 1024L * 1024L
const val UNKNOWN_PART_RESERVATION_BYTES = 100L * 1024L * 1024L const val UNKNOWN_PART_RESERVATION_BYTES = 100L * 1024L * 1024L
@@ -1,9 +1,6 @@
package com.audiobookshelf.app.managers package com.audiobookshelf.app.managers
import android.content.Context import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
@@ -11,10 +8,11 @@ import androidx.work.Worker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.models.DownloadItem import com.audiobookshelf.app.models.DownloadItem
import com.audiobookshelf.app.plugins.AbsLogger
import java.io.File import java.io.File
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** Removes terminally failed downloads after their retention window elapses. */ /** Removes only staging data for terminally failed downloads after their retention window. */
object IncompleteDownloadCleanup { object IncompleteDownloadCleanup {
private const val tag = "IncompleteDownloadCleanup" private const val tag = "IncompleteDownloadCleanup"
private const val RETENTION_MS = 24L * 60L * 60L * 1000L private const val RETENTION_MS = 24L * 60L * 60L * 1000L
@@ -22,6 +20,7 @@ object IncompleteDownloadCleanup {
fun schedule(context: Context, item: DownloadItem) { fun schedule(context: Context, item: DownloadItem) {
val failedAt = item.terminalFailureAt ?: return val failedAt = item.terminalFailureAt ?: return
if (item.stagingCleanupAt != null) return
val delay = (failedAt + RETENTION_MS - System.currentTimeMillis()).coerceAtLeast(0L) val delay = (failedAt + RETENTION_MS - System.currentTimeMillis()).coerceAtLeast(0L)
val request = OneTimeWorkRequestBuilder<IncompleteDownloadCleanupWorker>() val request = OneTimeWorkRequestBuilder<IncompleteDownloadCleanupWorker>()
.setInitialDelay(delay, TimeUnit.MILLISECONDS) .setInitialDelay(delay, TimeUnit.MILLISECONDS)
@@ -34,7 +33,8 @@ object IncompleteDownloadCleanup {
WorkManager.getInstance(context).cancelUniqueWork(WORK_PREFIX + itemId) WorkManager.getInstance(context).cancelUniqueWork(WORK_PREFIX + itemId)
} }
/** Removes failures retained longer than 24 hours when scheduled work did not run. */ /** Cleans staging data retained longer than 24 hours when scheduled work did not run. */
@Synchronized
fun cleanupExpired(context: Context) { fun cleanupExpired(context: Context) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
DeviceManager.dbManager.getDownloadItems() DeviceManager.dbManager.getDownloadItems()
@@ -46,6 +46,7 @@ object IncompleteDownloadCleanup {
private fun isEligible(item: DownloadItem, now: Long): Boolean { private fun isEligible(item: DownloadItem, now: Long): Boolean {
val failedAt = item.terminalFailureAt ?: return false val failedAt = item.terminalFailureAt ?: return false
if (item.stagingCleanupAt != null) return false
if (now - failedAt < RETENTION_MS) return false if (now - failedAt < RETENTION_MS) return false
return item.downloadItemParts.all { part -> return item.downloadItemParts.all { part ->
part.moved || (part.failed && !part.isMoving) part.moved || (part.failed && !part.isMoving)
@@ -55,21 +56,15 @@ object IncompleteDownloadCleanup {
private fun deleteItem(context: Context, item: DownloadItem) { private fun deleteItem(context: Context, item: DownloadItem) {
item.downloadItemParts.forEach { part -> item.downloadItemParts.forEach { part ->
deleteAppOwnedFile(context, File(part.destinationPath)) deleteAppOwnedFile(context, File(part.destinationPath))
if (part.isInternalStorage && part.moved) { if (!part.moved) {
deleteAppOwnedFile(context, File(part.finalDestinationPath)) part.bytesDownloaded = 0L
} else if (!part.isInternalStorage && part.moved) { part.completed = false
part.completedDestinationUri?.let { uriString ->
try {
DocumentFile.fromSingleUri(context, Uri.parse(uriString))?.delete()
} catch (e: Exception) {
Log.w(tag, "Could not delete expired SAF document for ${part.filename}", e)
}
}
} }
} }
DeviceManager.dbManager.removeDownloadItem(item.id) item.stagingCleanupAt = System.currentTimeMillis()
DeviceManager.dbManager.saveDownloadItem(item)
cancel(context, item.id) cancel(context, item.id)
Log.i(tag, "Deleted terminally failed download item ${item.id}") AbsLogger.info(tag, "Deleted staging files for terminally failed download item ${item.id}")
} }
private fun deleteAppOwnedFile(context: Context, file: File) { private fun deleteAppOwnedFile(context: Context, file: File) {
@@ -77,10 +72,10 @@ object IncompleteDownloadCleanup {
val internal = context.filesDir.absolutePath val internal = context.filesDir.absolutePath
val external = context.getExternalFilesDir(null)?.absolutePath val external = context.getExternalFilesDir(null)?.absolutePath
if (path.startsWith(internal) || (external != null && path.startsWith(external))) { if (path.startsWith(internal) || (external != null && path.startsWith(external))) {
if (file.exists() && !file.delete()) Log.w(tag, "Could not delete expired staging file $path") if (file.exists() && !file.delete()) AbsLogger.error(tag, "Could not delete expired staging file $path")
file.parentFile?.takeIf { it.isDirectory && it.list()?.isEmpty() == true }?.delete() file.parentFile?.takeIf { it.isDirectory && it.list()?.isEmpty() == true }?.delete()
} else { } else {
Log.w(tag, "Refusing to delete non-app-owned path $path") AbsLogger.error(tag, "Refusing to delete non-app-owned path $path")
} }
} }
} }
@@ -1,9 +1,11 @@
package com.audiobookshelf.app.managers package com.audiobookshelf.app.managers
import android.util.Log import com.audiobookshelf.app.plugins.AbsLogger
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import okhttp3.Call import okhttp3.Call
import okhttp3.Callback import okhttp3.Callback
@@ -19,16 +21,49 @@ class InternalDownloadManager(
private val hasAvailableSpace: () -> Boolean private val hasAvailableSpace: () -> Boolean
) { ) {
private val tag = "InternalDownloadManager" private val tag = "InternalDownloadManager"
interface DownloadHandle {
fun cancel()
}
private class ActiveDownloadHandle : DownloadHandle {
private val cancelled = AtomicBoolean(false)
private val activeCall = AtomicReference<Call?>()
fun setCall(call: Call) {
activeCall.set(call)
if (cancelled.get()) call.cancel()
}
override fun cancel() {
cancelled.set(true)
activeCall.get()?.cancel()
}
}
/** /**
* Starts or resumes a download. * Starts or resumes a download.
* *
* @param url download URL * @param url download URL
* @param token access token sent in the Authorization header * @param token access token sent in the Authorization header
* @return active call, used to cancel a stalled transfer * @return logical handle used to cancel the active request, including a restarted request
*/ */
fun download(url: String, token: String): Call { fun download(url: String, token: String): DownloadHandle {
destinationFile.parentFile?.mkdirs() destinationFile.parentFile?.mkdirs()
val existingBytes = destinationFile.takeIf { it.exists() }?.length() ?: 0L val handle = ActiveDownloadHandle()
startRequest(url, token, handle, allowRestart = true)
return handle
}
private fun startRequest(
url: String,
token: String,
handle: ActiveDownloadHandle,
allowRestart: Boolean
) {
var existingBytes = destinationFile.takeIf { it.exists() }?.length() ?: 0L
AbsLogger.info(
tag,
"Starting ${if (existingBytes > 0L) "resumed" else "new"} download for ${destinationFile.name} at byte $existingBytes")
val request = val request =
Request.Builder() Request.Builder()
.url(url) .url(url)
@@ -37,20 +72,39 @@ class InternalDownloadManager(
.apply { if (existingBytes > 0L) header("Range", "bytes=$existingBytes-") } .apply { if (existingBytes > 0L) header("Range", "bytes=$existingBytes-") }
.build() .build()
val call = client.newCall(request) val call = client.newCall(request)
handle.setCall(call)
call.enqueue( call.enqueue(
object : Callback { object : Callback {
override fun onFailure(call: Call, e: IOException) { override fun onFailure(call: Call, e: IOException) {
Log.e(tag, "Download URL failed", e) AbsLogger.error(tag, "Download request failed for ${destinationFile.name}: ${e.message}")
progressCallback.onComplete(true) progressCallback.onComplete(true)
} }
override fun onResponse(call: Call, response: Response) { override fun onResponse(call: Call, response: Response) {
response.use { response.use {
try { try {
if (response.code == 416 && expectedSize > 0L && existingBytes == expectedSize if (response.code == 401) {
) { AbsLogger.error(tag, "Download unauthorized (401) for ${destinationFile.name}")
progressCallback.onProgress(existingBytes, 100L) progressCallback.onAuthError()
progressCallback.onComplete(false) return
}
if (response.code == 416) {
val serverSize =
response.header("Content-Range")
?.removePrefix("bytes */")
?.toLongOrNull()
if (serverSize != null) progressCallback.onSizeResolved(serverSize)
if (serverSize != null && existingBytes == serverSize) {
progressCallback.onProgress(existingBytes, 100L)
AbsLogger.info(tag, "Download completed for ${destinationFile.name} ($existingBytes bytes)")
progressCallback.onComplete(false)
} else if (allowRestart && destinationFile.delete()) {
AbsLogger.info(tag, "Restarting stale range from byte zero for ${destinationFile.name}")
startRequest(url, token, handle, allowRestart = false)
} else {
AbsLogger.error(tag, "Could not recover invalid range for ${destinationFile.name} at byte $existingBytes")
progressCallback.onComplete(true)
}
return return
} }
val append = val append =
@@ -58,24 +112,27 @@ class InternalDownloadManager(
response.code == 206 && response.code == 206 &&
hasExpectedRange(response, existingBytes) hasExpectedRange(response, existingBytes)
if (existingBytes > 0L && !append && response.code != 200) { if (existingBytes > 0L && !append && response.code != 200) {
Log.e( AbsLogger.error(
tag, tag,
"Invalid resume response ${response.code} for offset $existingBytes" "Invalid resume response ${response.code} for ${destinationFile.name} at byte $existingBytes"
) )
progressCallback.onComplete(true) progressCallback.onComplete(true)
return return
} }
if (!response.isSuccessful || response.body == null) { if (!response.isSuccessful || response.body == null) {
Log.e(tag, "Download HTTP failure ${response.code}") AbsLogger.error(tag, "Download HTTP failure ${response.code} for ${destinationFile.name}")
progressCallback.onComplete(true) progressCallback.onComplete(true)
return return
} }
val startingBytes = if (append) existingBytes else 0L val startingBytes = if (append) existingBytes else 0L
val responseLength = response.body!!.contentLength() val responseLength = response.body!!.contentLength()
val serverSize =
if (append) contentRangeTotal(response)
else responseLength.takeIf { it >= 0L }
if (serverSize != null) progressCallback.onSizeResolved(serverSize)
val totalLength = val totalLength =
if (expectedSize > 0L) expectedSize serverSize ?: if (expectedSize > 0L) expectedSize else 0L
else if (responseLength >= 0L) startingBytes + responseLength else 0L
FileOutputStream(destinationFile, append).use { output -> FileOutputStream(destinationFile, append).use { output ->
response.body!!.byteStream().use { input -> response.body!!.byteStream().use { input ->
@@ -95,24 +152,28 @@ class InternalDownloadManager(
} }
} }
if (expectedSize > 0L && destinationFile.length() != expectedSize) { val downloadedSize = destinationFile.length()
Log.e( if (serverSize != null && downloadedSize != serverSize) {
AbsLogger.error(
tag, tag,
"Downloaded size ${destinationFile.length()} did not match $expectedSize" "Downloaded size for ${destinationFile.name} was $downloadedSize, expected server size $serverSize"
) )
progressCallback.onComplete(true) progressCallback.onComplete(true)
} else { } else {
AbsLogger.info(
tag,
"Download completed for ${destinationFile.name} ($downloadedSize bytes)"
)
progressCallback.onComplete(false) progressCallback.onComplete(false)
} }
} catch (e: IOException) { } catch (e: IOException) {
Log.e(tag, "Could not write staging file", e) AbsLogger.error(tag, "Could not write staging file ${destinationFile.name}: ${e.message}")
progressCallback.onComplete(true) progressCallback.onComplete(true)
} }
} }
} }
} }
) )
return call
} }
private fun hasExpectedRange(response: Response, offset: Long): Boolean { private fun hasExpectedRange(response: Response, offset: Long): Boolean {
@@ -122,9 +183,16 @@ class InternalDownloadManager(
match.groupValues[2].toLongOrNull()?.let { it >= offset } == true match.groupValues[2].toLongOrNull()?.let { it >= offset } == true
} }
private fun contentRangeTotal(response: Response): Long? =
CONTENT_RANGE.matchEntire(response.header("Content-Range") ?: "")
?.groupValues
?.get(3)
?.takeUnless { it == "*" }
?.toLongOrNull()
private companion object { private companion object {
const val CHUNK_SIZE = 512 * 1024 // 512 KB const val CHUNK_SIZE = 512 * 1024 // 512 KB
val CONTENT_RANGE = Regex("bytes (\\d+)-(\\d+)/(?:\\d+|\\*)") val CONTENT_RANGE = Regex("bytes (\\d+)-(\\d+)/(\\d+|\\*)")
val client = val client =
OkHttpClient.Builder() OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
@@ -20,7 +20,8 @@ data class DownloadItem(
val itemSubfolder: String, val itemSubfolder: String,
val media: MediaType, val media: MediaType,
val downloadItemParts: MutableList<DownloadItemPart>, val downloadItemParts: MutableList<DownloadItemPart>,
@JsonIgnore var terminalFailureAt: Long? = null @JsonIgnore var terminalFailureAt: Long? = null,
@JsonIgnore var stagingCleanupAt: Long? = null
) { ) {
@get:JsonIgnore @get:JsonIgnore
val isInternalStorage val isInternalStorage
@@ -28,7 +29,9 @@ data class DownloadItem(
@get:JsonIgnore @get:JsonIgnore
val isDownloadFinished val isDownloadFinished
get() = !downloadItemParts.any { !it.completed || it.isMoving || it.failed } get() = downloadItemParts.isNotEmpty() && downloadItemParts.all {
it.completed && it.moved && !it.isMoving && !it.failed
}
@JsonIgnore @JsonIgnore
fun getNextDownloadItemParts(limit: Int): MutableList<DownloadItemPart> { fun getNextDownloadItemParts(limit: Int): MutableList<DownloadItemPart> {
@@ -14,7 +14,7 @@ data class DownloadItemPart(
val id: String, val id: String,
val downloadItemId: String, val downloadItemId: String,
val filename: String, val filename: String,
val fileSize: Long, var fileSize: Long,
@JsonIgnore val destinationPath: String, @JsonIgnore val destinationPath: String,
val finalDestinationPath:String, val finalDestinationPath:String,
val serverPath: String, val serverPath: String,
@@ -38,7 +38,9 @@ data class DownloadItemPart(
var progress: Long, var progress: Long,
var bytesDownloaded: Long, var bytesDownloaded: Long,
@JsonIgnore var retryCount: Int = 0, @JsonIgnore var retryCount: Int = 0,
@JsonIgnore var waitingForSpace: Boolean = false @JsonIgnore var authRetryCount: Int = 0,
@JsonIgnore var waitingForSpace: Boolean = false,
@JsonIgnore var reusedExistingFile: Boolean = false
) { ) {
companion object { companion object {
fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, ebookFile: EBookFile?, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart { fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, ebookFile: EBookFile?, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart {
@@ -74,7 +76,8 @@ data class DownloadItemPart(
downloadId = null, downloadId = null,
lastUpdateTime = null, lastUpdateTime = null,
progress = 0, progress = 0,
bytesDownloaded = 0 bytesDownloaded = 0,
reusedExistingFile = false
) )
} }
} }
@@ -1,7 +1,6 @@
package com.audiobookshelf.app.plugins package com.audiobookshelf.app.plugins
import android.os.Environment import android.os.Environment
import android.util.Log
import com.audiobookshelf.app.MainActivity import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.data.* import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
@@ -84,57 +83,60 @@ class AbsDownloader : Plugin() {
var episodeId = call.data.getString("episodeId").toString() var episodeId = call.data.getString("episodeId").toString()
if (episodeId == "null") episodeId = "" if (episodeId == "null") episodeId = ""
var localFolderId = call.data.getString("localFolderId", "").toString() var localFolderId = call.data.getString("localFolderId", "").toString()
Log.d(tag, "Download library item $libraryItemId to folder $localFolderId / episode: $episodeId") AbsLogger.info(tag, "Requested download for item $libraryItemId${if (episodeId.isEmpty()) "" else " / episode $episodeId"}")
val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId" val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId"
if (downloadItemManager.downloadItemQueue.find { it.id == downloadId } != null) { DownloadServiceHost.retryExisting(mainActivity, downloadId) { result ->
Log.d(tag, "Download already started for this media entity $downloadId") when (result) {
return call.resolve(JSObject("{\"error\":\"Download already started for this media entity\"}")) DownloadServiceHost.ExistingDownloadResult.RETRIED -> call.resolve()
} DownloadServiceHost.ExistingDownloadResult.ACTIVE -> {
AbsLogger.info(tag, "Download already active for $downloadId")
apiHandler.getLibraryItemWithProgress(libraryItemId, episodeId) { libraryItem -> call.resolve(JSObject("{\"error\":\"Download already started for this media entity\"}"))
if (libraryItem == null) {
call.resolve(JSObject("{\"error\":\"Server request failed\"}"))
} else {
Log.d(tag, "Got library item from server ${libraryItem.id}")
if (localFolderId == "") {
localFolderId = "internal-${libraryItem.mediaType}"
} }
var localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId) DownloadServiceHost.ExistingDownloadResult.SERVICE_START_FAILED ->
call.resolve(JSObject("{\"error\":\"Unable to start the Android download service\"}"))
if (localFolder == null && localFolderId.startsWith("internal-")) { DownloadServiceHost.ExistingDownloadResult.NOT_FOUND -> {
Log.d(tag, "Creating new App Storage internal LocalFolder $localFolderId") apiHandler.getLibraryItemWithProgress(libraryItemId, episodeId) { libraryItem ->
localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "internal", libraryItem.mediaType) if (libraryItem == null) {
DeviceManager.dbManager.saveLocalFolder(localFolder) call.resolve(JSObject("{\"error\":\"Server request failed\"}"))
}
if (localFolder != null) {
if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") {
Log.e(tag, "Library item is not a podcast but episode was requested")
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
} else if (episodeId.isNotEmpty()) {
val podcast = libraryItem.media as Podcast
val episode = podcast.episodes?.find { podcastEpisode ->
podcastEpisode.id == episodeId
}
if (episode == null) {
call.resolve(JSObject("{\"error\":\"Invalid podcast episode not found\"}"))
} else { } else {
startLibraryItemDownload(libraryItem, localFolder, episode) AbsLogger.info(tag, "Preparing download for ${libraryItem.id}")
call.resolve()
if (localFolderId == "") localFolderId = "internal-${libraryItem.mediaType}"
var localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
if (localFolder == null && localFolderId.startsWith("internal-")) {
AbsLogger.info(tag, "Creating internal download folder $localFolderId")
localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "internal", libraryItem.mediaType)
DeviceManager.dbManager.saveLocalFolder(localFolder)
}
if (localFolder == null) {
call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}"))
} else if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") {
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
} else if (episodeId.isNotEmpty()) {
val podcast = libraryItem.media as Podcast
val episode = podcast.episodes?.find { it.id == episodeId }
if (episode == null) {
call.resolve(JSObject("{\"error\":\"Invalid podcast episode not found\"}"))
} else {
startLibraryItemDownload(libraryItem, localFolder, episode) { error -> resolveDownloadCall(call, error) }
}
} else {
startLibraryItemDownload(libraryItem, localFolder, null) { error -> resolveDownloadCall(call, error) }
}
} }
} else {
startLibraryItemDownload(libraryItem, localFolder, null)
call.resolve()
} }
} else {
call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}"))
} }
} }
} }
} }
private fun resolveDownloadCall(call: PluginCall, error: String?) {
if (error == null) call.resolve()
else call.resolve(JSObject().put("error", error))
}
// Item filenames could be the same if they are in sub-folders, this will make them unique // Item filenames could be the same if they are in sub-folders, this will make them unique
private fun getFilenameFromRelPath(relPath: String): String { private fun getFilenameFromRelPath(relPath: String): String {
var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_") var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_")
@@ -155,7 +157,12 @@ class AbsDownloader : Plugin() {
return newTitle return newTitle
} }
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) { private fun startLibraryItemDownload(
libraryItem: LibraryItem,
localFolder: LocalFolder,
episode: PodcastEpisode?,
callback: (String?) -> Unit
) {
val isInternal = localFolder.id.startsWith("internal-") val isInternal = localFolder.id.startsWith("internal-")
val finalInternalFolderPath = "${mainActivity.filesDir}/downloads/${libraryItem.id}" val finalInternalFolderPath = "${mainActivity.filesDir}/downloads/${libraryItem.id}"
@@ -166,14 +173,13 @@ class AbsDownloader : Plugin() {
"${mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: mainActivity.filesDir}/download-staging/${libraryItem.id}" "${mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: mainActivity.filesDir}/download-staging/${libraryItem.id}"
} }
Log.d(tag, "downloadCacheDirectory=$tempFolderPath")
if (libraryItem.mediaType == "book") { if (libraryItem.mediaType == "book") {
val bookTitle = cleanStringForFileSystem(libraryItem.media.metadata.title) val bookTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
val bookAuthor = cleanStringForFileSystem(libraryItem.media.metadata.getAuthorDisplayName()) val bookAuthor = cleanStringForFileSystem(libraryItem.media.metadata.getAuthorDisplayName())
val tracks = libraryItem.media.getAudioTracks() val tracks = libraryItem.media.getAudioTracks()
Log.d(tag, "Starting library item download with ${tracks.size} tracks") AbsLogger.info(tag, "Queueing library item download with ${tracks.size} files")
val itemSubfolder = "$bookAuthor/$bookTitle" val itemSubfolder = "$bookAuthor/$bookTitle"
val itemFolderPath = if (isInternal) finalInternalFolderPath else "${localFolder.absolutePath}/$itemSubfolder" val itemFolderPath = if (isInternal) finalInternalFolderPath else "${localFolder.absolutePath}/$itemSubfolder"
val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, libraryItem.media, mutableListOf()) val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, libraryItem.media, mutableListOf())
@@ -200,7 +206,6 @@ class AbsDownloader : Plugin() {
val serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download" val serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download"
val destinationFilename = getFilenameFromRelPath(audioTrack.relPath) val destinationFilename = getFilenameFromRelPath(audioTrack.relPath)
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
val finalDestinationFile = File("$itemFolderPath/$destinationFilename") val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
val destinationFile = File("$tempFolderPath/$destinationFilename.part") val destinationFile = File("$tempFolderPath/$destinationFilename.part")
@@ -224,8 +229,8 @@ class AbsDownloader : Plugin() {
downloadItem.downloadItemParts.add(downloadItemPart) downloadItem.downloadItemParts.add(downloadItemPart)
} }
DownloadServiceHost.enqueue(mainActivity, downloadItem) DownloadServiceHost.enqueue(mainActivity, downloadItem, callback)
} } else callback("No downloadable files found")
} else { } else {
// Podcast episode download // Podcast episode download
val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title) val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
@@ -234,14 +239,13 @@ class AbsDownloader : Plugin() {
val audioFileIno = episode?.audioFile?.ino val audioFileIno = episode?.audioFile?.ino
val fileSize = audioTrack?.metadata?.size ?: 0 val fileSize = audioTrack?.metadata?.size ?: 0
Log.d(tag, "Starting podcast episode download") AbsLogger.info(tag, "Queueing podcast episode download")
val itemFolderPath = if (isInternal) finalInternalFolderPath else "${localFolder.absolutePath}/$podcastTitle" val itemFolderPath = if (isInternal) finalInternalFolderPath else "${localFolder.absolutePath}/$podcastTitle"
val downloadItemId = "${libraryItem.id}-${episode?.id}" val downloadItemId = "${libraryItem.id}-${episode?.id}"
val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, podcastTitle, libraryItem.media, mutableListOf()) val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, podcastTitle, libraryItem.media, mutableListOf())
var serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download" var serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download"
var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "") var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "")
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
var destinationFile = File("$tempFolderPath/$destinationFilename.part") var destinationFile = File("$tempFolderPath/$destinationFilename.part")
var finalDestinationFile = File("$itemFolderPath/$destinationFilename") var finalDestinationFile = File("$itemFolderPath/$destinationFilename")
@@ -262,7 +266,7 @@ class AbsDownloader : Plugin() {
downloadItem.downloadItemParts.add(downloadItemPart) downloadItem.downloadItemParts.add(downloadItemPart)
} }
DownloadServiceHost.enqueue(mainActivity, downloadItem) DownloadServiceHost.enqueue(mainActivity, downloadItem, callback)
} }
} }
} }
@@ -167,105 +167,116 @@ class ApiHandler(var ctx:Context) {
* 2. Make a request to /auth/refresh endpoint with the refresh token * 2. Make a request to /auth/refresh endpoint with the refresh token
* 3. Update the stored tokens with the new access token * 3. Update the stored tokens with the new access token
* 4. Retry the original request with the new access token * 4. Retry the original request with the new access token
* 5. If refresh fails, handle logout * 5. If refresh fails, fail the request ([refreshAuthTokens] owns clearing the session)
* *
* @param originalRequest The original request that failed with 401 * @param originalRequest The original request that failed with 401
* @param httpClient The HTTP client to use for the request * @param httpClient The HTTP client to use for the request
* @param callback The callback to return the response * @param callback The callback to return the response
*/ */
private fun handleTokenRefresh(originalRequest: Request, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) { private fun handleTokenRefresh(originalRequest: Request, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) {
try { val serverConnectionConfigId = DeviceManager.serverConnectionConfigId
AbsLogger.info(tag, "handleTokenRefresh: Attempting to refresh auth tokens for server ${DeviceManager.serverConnectionConfigString}") refreshAuthTokens(serverConnectionConfigId, httpClient) { result ->
if (result is RefreshResult.Success) {
retryOriginalRequest(originalRequest, result.accessToken, httpClient, callback)
} else {
callback(JSObject().put("error", "Authentication failed - login again"))
}
}
}
// Get current server connection config ID sealed interface RefreshResult {
val serverConnectionConfigId = DeviceManager.serverConnectionConfigId data class Success(val accessToken: String) : RefreshResult
if (serverConnectionConfigId.isEmpty()) {
AbsLogger.error(tag, "handleTokenRefresh: Unable to refresh auth tokens. No server connection config ID") /** The server rejected the refresh token, so the session has already been cleared. */
val errorObj = JSObject() data object Rejected : RefreshResult
errorObj.put("error", "No server connection available")
callback(errorObj) /** The refresh could not be completed. Credentials are untouched and the caller may retry. */
return data object Transient : RefreshResult
}
/** Refreshes tokens for a specific saved server, including downloads queued while another server is active. */
fun refreshAuthTokens(
serverConnectionConfigId: String,
httpClient: OkHttpClient? = null,
onResult: (RefreshResult) -> Unit
) {
val config = DeviceManager.getServerConnectionConfig(serverConnectionConfigId)
val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId)
if (config == null || refreshToken.isNullOrEmpty()) {
AbsLogger.error(tag, "No refresh token or server configuration for $serverConnectionConfigId")
handleRefreshRejected(serverConnectionConfigId)
onResult(RefreshResult.Rejected)
return
}
val request = try {
Request.Builder()
.url("${config.address}/auth/refresh")
.addHeader("x-refresh-token", refreshToken)
.addHeader("Content-Type", "application/json")
.post(EMPTY_REQUEST)
.build()
} catch (e: Exception) {
AbsLogger.error(tag, "Could not create refresh request for ${config.name}: ${e.message}")
onResult(RefreshResult.Transient)
return
}
(httpClient ?: defaultClient).newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
AbsLogger.error(tag, "Token refresh failed for ${config.name}: ${e.message}")
onResult(RefreshResult.Transient)
} }
// Get refresh token from secure storage override fun onResponse(call: Call, response: Response) {
val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId) response.use {
if (refreshToken.isNullOrEmpty()) { if (!it.isSuccessful) {
AbsLogger.error(tag, "handleTokenRefresh: Unable to refresh auth tokens. No refresh token available for server ${DeviceManager.serverConnectionConfigString}") AbsLogger.error(tag, "Token refresh returned ${it.code} for ${config.name}")
val errorObj = JSObject() if (it.code != 401 && it.code != 403) {
errorObj.put("error", "No refresh token available") onResult(RefreshResult.Transient)
callback(errorObj)
return
}
Log.d(tag, "handleTokenRefresh: Retrieved refresh token, attempting to refresh access token")
// Create refresh token request
val refreshEndpoint = "${DeviceManager.serverAddress}/auth/refresh"
val refreshRequest = Request.Builder()
.url(refreshEndpoint)
.addHeader("x-refresh-token", refreshToken)
.addHeader("Content-Type", "application/json")
.post(EMPTY_REQUEST)
.build()
// Make the refresh request
val client = httpClient ?: defaultClient
client.newCall(refreshRequest).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Log.e(tag, "handleTokenRefresh: Failed to connect to refresh endpoint", e)
AbsLogger.error(tag, "handleTokenRefresh: Failed to connect to refresh endpoint for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})")
handleRefreshFailure(callback)
}
override fun onResponse(call: Call, response: Response) {
response.use {
if (!it.isSuccessful) {
AbsLogger.error(tag, "handleTokenRefresh: Refresh request failed with status ${it.code} for server ${DeviceManager.serverConnectionConfigString}")
handleRefreshFailure(callback)
return return
} }
handleRefreshRejected(serverConnectionConfigId)
val bodyString = it.body!!.string() onResult(RefreshResult.Rejected)
try { return
val responseJson = JSONObject(bodyString) }
val userObj = responseJson.optJSONObject("user") try {
val user = JSONObject(it.body!!.string()).optJSONObject("user")
if (userObj == null) { val accessToken = user?.optString("accessToken").orEmpty()
AbsLogger.error(tag, "handleTokenRefresh: No user object in refresh response for server ${DeviceManager.serverConnectionConfigString}") if (accessToken.isEmpty()) {
handleRefreshFailure(callback) AbsLogger.error(tag, "Refresh response had no access token for ${config.name}")
return onResult(RefreshResult.Transient)
} return
val newAccessToken = userObj.optString("accessToken")
val newRefreshToken = userObj.optString("refreshToken")
if (newAccessToken.isEmpty()) {
AbsLogger.error(tag, "handleTokenRefresh: No access token in refresh response for server ${DeviceManager.serverConnectionConfigString}")
handleRefreshFailure(callback)
return
}
Log.d(tag, "handleTokenRefresh: Successfully obtained new access token")
// Update tokens in secure storage and device manager
updateTokens(newAccessToken, newRefreshToken.ifEmpty { refreshToken }, serverConnectionConfigId)
// Retry the original request with the new access token
Log.d(tag, "handleTokenRefresh: Retrying original request with new token")
retryOriginalRequest(originalRequest, newAccessToken, httpClient, callback)
} catch (e: Exception) {
Log.e(tag, "handleTokenRefresh: Failed to parse refresh response", e)
AbsLogger.error(tag, "handleTokenRefresh: Failed to parse refresh response for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})")
handleRefreshFailure(callback)
} }
updateTokens(accessToken, user?.optString("refreshToken").orEmpty().ifEmpty { refreshToken }, serverConnectionConfigId)
onResult(RefreshResult.Success(accessToken))
} catch (e: Exception) {
AbsLogger.error(tag, "Could not parse refresh response for ${config.name}: ${e.message}")
onResult(RefreshResult.Transient)
} }
} }
}) }
})
}
/**
* Clears only the server that rejected the refresh token; queued downloads can target a non-active server.
*
* Only call this when the server explicitly rejected the refresh token. Transient failures should not log the user out
*/
private fun handleRefreshRejected(serverConnectionConfigId: String) {
// Must not throw: callers still have to report the refresh result to an in-flight request or download.
try {
secureStorage.removeRefreshToken(serverConnectionConfigId)
if (DeviceManager.serverConnectionConfigId != serverConnectionConfigId) return
DeviceManager.serverConnectionConfig = null
DeviceManager.deviceData.lastServerConnectionConfigId = null
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
if (checkAbsDatabaseNotifyListenersInitted()) {
absDatabaseNotifyListeners(
"onTokenRefreshFailure",
JSObject().put("error", "Token refresh failed").put("serverConnectionConfigId", serverConnectionConfigId))
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(tag, "handleTokenRefresh: Unexpected error during token refresh", e) AbsLogger.error(tag, "Could not clear session for $serverConnectionConfigId: ${e.message}")
handleRefreshFailure(callback)
} }
} }
@@ -283,15 +294,15 @@ class ApiHandler(var ctx:Context) {
Log.d(tag, "updateTokens: Updated refresh token in secure storage") Log.d(tag, "updateTokens: Updated refresh token in secure storage")
} }
// Update the access token in the current server connection config // The refreshed connection may be queued in the downloader rather than currently active.
DeviceManager.serverConnectionConfig?.let { config -> DeviceManager.getServerConnectionConfig(serverConnectionConfigId)?.let { config ->
config.token = newAccessToken config.token = newAccessToken
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData) DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
Log.d(tag, "updateTokens: Updated access token in server connection config") Log.d(tag, "updateTokens: Updated access token in server connection config")
} }
// Send access token to Webview frontend // Send access token to Webview frontend
if (checkAbsDatabaseNotifyListenersInitted()) { if (DeviceManager.serverConnectionConfigId == serverConnectionConfigId && checkAbsDatabaseNotifyListenersInitted()) {
val tokenJsObject = JSObject() val tokenJsObject = JSObject()
tokenJsObject.put("accessToken", newAccessToken) tokenJsObject.put("accessToken", newAccessToken)
absDatabaseNotifyListeners("onTokenRefresh", tokenJsObject) absDatabaseNotifyListeners("onTokenRefresh", tokenJsObject)
@@ -379,50 +390,6 @@ class ApiHandler(var ctx:Context) {
} }
} }
/**
* Handles the case when token refresh fails
* This will clear the current session and notify the callback
*
* @param callback The callback to return the error
*/
private fun handleRefreshFailure(callback: (JSObject) -> Unit) {
try {
Log.d(tag, "handleRefreshFailure: Token refresh failed, clearing session")
// Clear the current server connection
DeviceManager.serverConnectionConfig = null
DeviceManager.deviceData.lastServerConnectionConfigId = null
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
// Remove refresh token from secure storage
val serverConnectionConfigId = DeviceManager.serverConnectionConfigId
if (serverConnectionConfigId.isNotEmpty()) {
secureStorage.removeRefreshToken(serverConnectionConfigId)
}
val errorObj = JSObject()
errorObj.put("error", "Authentication failed - please login again")
callback(errorObj)
if (checkAbsDatabaseNotifyListenersInitted()) {
val tokenJsObject = JSObject()
tokenJsObject.put("error", "Token refresh failed")
if (serverConnectionConfigId.isNotEmpty()) {
tokenJsObject.put("serverConnectionConfigId", serverConnectionConfigId)
}
absDatabaseNotifyListeners("onTokenRefreshFailure", tokenJsObject)
} else {
// Can happen if Webview is never run
Log.i(tag, "AbsDatabaseNotifyListeners is not initialized so cannot send token refresh failure notification")
}
} catch (e: Exception) {
Log.e(tag, "handleRefreshFailure: Error during failure handling", e)
val errorObj = JSObject()
errorObj.put("error", "Authentication failed")
callback(errorObj)
}
}
fun getCurrentUser(cb: (User?) -> Unit) { fun getCurrentUser(cb: (User?) -> Unit) {
getRequest("/api/me", null, null) { getRequest("/api/me", null, null) {
if (it.has("error")) { if (it.has("error")) {
@@ -28,7 +28,7 @@ class DownloadService : Service() {
ACTION_CANCEL -> DownloadServiceHost.cancelAll(this) ACTION_CANCEL -> DownloadServiceHost.cancelAll(this)
else -> { else -> {
startForegroundWithType(DownloadServiceHost.notificationStrings(this).preparing) startForegroundWithType(DownloadServiceHost.notificationStrings(this).preparing)
DownloadServiceHost.ensure(this) DownloadServiceHost.startWork(this)
} }
} }
return START_STICKY return START_STICKY
@@ -5,12 +5,21 @@ import androidx.core.content.ContextCompat
import com.audiobookshelf.app.device.FolderScanner import com.audiobookshelf.app.device.FolderScanner
import com.audiobookshelf.app.managers.DbManager import com.audiobookshelf.app.managers.DbManager
import com.audiobookshelf.app.managers.DownloadItemManager import com.audiobookshelf.app.managers.DownloadItemManager
import com.audiobookshelf.app.managers.IncompleteDownloadCleanup
import com.audiobookshelf.app.models.DownloadItem import com.audiobookshelf.app.models.DownloadItem
import com.audiobookshelf.app.plugins.AbsLogger
import com.getcapacitor.JSObject import com.getcapacitor.JSObject
import java.util.Collections import java.util.Collections
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/** Shared process owner used by the foreground service and the Capacitor bridge. */ /** Shared process owner used by the foreground service and the Capacitor bridge. */
object DownloadServiceHost { object DownloadServiceHost {
enum class ExistingDownloadResult { NOT_FOUND, ACTIVE, RETRIED, SERVICE_START_FAILED }
data class NotificationStrings( data class NotificationStrings(
val preparing: String, val preparing: String,
val downloadingFile: String, val downloadingFile: String,
@@ -21,9 +30,11 @@ object DownloadServiceHost {
private var manager: DownloadItemManager? = null private var manager: DownloadItemManager? = null
private var bridgeEmitter: DownloadItemManager.DownloadEventEmitter = NoopEmitter private var bridgeEmitter: DownloadItemManager.DownloadEventEmitter = NoopEmitter
private var service: DownloadService? = null @Volatile private var service: DownloadService? = null
@Volatile private var bridgeReady = false @Volatile private var bridgeReady = false
private val deferredCompletions = Collections.synchronizedList(mutableListOf<JSObject>()) private val deferredCompletions = Collections.synchronizedList(mutableListOf<JSObject>())
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var restoreJob: Job? = null
@Synchronized @Synchronized
fun ensure(context: Context): DownloadItemManager { fun ensure(context: Context): DownloadItemManager {
@@ -31,7 +42,11 @@ object DownloadServiceHost {
val appContext = context.applicationContext val appContext = context.applicationContext
DbManager.initialize(appContext) DbManager.initialize(appContext)
manager = DownloadItemManager(FolderScanner(appContext), appContext, ForwardingEmitter) manager = DownloadItemManager(FolderScanner(appContext), appContext, ForwardingEmitter)
manager!!.restoreQueue() restoreJob = scope.launch {
IncompleteDownloadCleanup.cleanupExpired(appContext)
manager!!.restoreQueue()
onRestoreComplete(appContext)
}
} }
return manager!! return manager!!
} }
@@ -41,14 +56,12 @@ object DownloadServiceHost {
fun attachBridge(context: Context, emitter: DownloadItemManager.DownloadEventEmitter) { fun attachBridge(context: Context, emitter: DownloadItemManager.DownloadEventEmitter) {
bridgeReady = false bridgeReady = false
bridgeEmitter = emitter bridgeEmitter = emitter
val queue = ensure(context) ensure(context).setEventEmitter(ForwardingEmitter)
queue.setEventEmitter(ForwardingEmitter)
bridgeReady = true bridgeReady = true
val completions = synchronized(deferredCompletions) { val completions = synchronized(deferredCompletions) {
deferredCompletions.toList().also { deferredCompletions.clear() } deferredCompletions.toList().also { deferredCompletions.clear() }
} }
completions.forEach(bridgeEmitter::onDownloadItemComplete) completions.forEach(bridgeEmitter::onDownloadItemComplete)
if (queue.hasWork()) startService(context)
} }
@Synchronized @Synchronized
@@ -57,14 +70,44 @@ object DownloadServiceHost {
bridgeEmitter = NoopEmitter bridgeEmitter = NoopEmitter
} }
@Synchronized fun enqueue(context: Context, item: DownloadItem, callback: (String?) -> Unit) {
fun enqueue(context: Context, item: DownloadItem) { val queue = ensure(context)
ensure(context).addDownloadItem(item) scope.launch {
startService(context) restoreJob?.join()
queue.addDownloadItem(item)
if (startService(context)) callback(null)
else callback("Unable to start the Android download service")
}
} }
@Synchronized fun retryExisting(
fun cancelAll(context: Context) { ensure(context).cancelAll() } context: Context,
downloadItemId: String,
callback: (ExistingDownloadResult) -> Unit
) {
val queue = ensure(context)
scope.launch {
restoreJob?.join()
val existing = queue.downloadItemQueue.find { it.id == downloadItemId }
if (existing == null) {
callback(ExistingDownloadResult.NOT_FOUND)
} else if (!queue.retryDownloadItem(downloadItemId)) {
callback(ExistingDownloadResult.ACTIVE)
} else if (startService(context)) {
callback(ExistingDownloadResult.RETRIED)
} else {
callback(ExistingDownloadResult.SERVICE_START_FAILED)
}
}
}
fun cancelAll(context: Context) {
val queue = ensure(context)
scope.launch {
restoreJob?.join()
queue.cancelAll()
}
}
fun setNotificationStrings( fun setNotificationStrings(
context: Context, context: Context,
@@ -96,10 +139,9 @@ object DownloadServiceHost {
preferences.getString(KEY_CANCEL, DEFAULT_CANCEL) ?: DEFAULT_CANCEL) preferences.getString(KEY_CANCEL, DEFAULT_CANCEL) ?: DEFAULT_CANCEL)
} }
@Synchronized
fun attachService(downloadService: DownloadService) { fun attachService(downloadService: DownloadService) {
service = downloadService synchronized(this) { service = downloadService }
service?.onQueueChanged(ensure(downloadService).hasWork()) startWork(downloadService)
} }
@Synchronized @Synchronized
@@ -107,8 +149,31 @@ object DownloadServiceHost {
if (service === downloadService) service = null if (service === downloadService) service = null
} }
private fun startService(context: Context) { fun startWork(context: Context) {
ContextCompat.startForegroundService(context, DownloadService.intent(context)) val queue = ensure(context)
scope.launch {
restoreJob?.join()
queue.resumeWork()
}
}
private fun onRestoreComplete(context: Context) {
val attachedService = synchronized(this) { service }
if (attachedService != null) {
manager?.resumeWork()
} else if (bridgeReady && manager?.hasWork() == true) {
startService(context)
}
}
private fun startService(context: Context): Boolean {
return try {
ContextCompat.startForegroundService(context, DownloadService.intent(context))
true
} catch (e: RuntimeException) {
AbsLogger.error(TAG, "Could not start download foreground service: ${e.message}")
false
}
} }
private object ForwardingEmitter : DownloadItemManager.DownloadEventEmitter { private object ForwardingEmitter : DownloadItemManager.DownloadEventEmitter {
@@ -144,4 +209,5 @@ object DownloadServiceHost {
private const val DEFAULT_WAITING_FOR_STORAGE = "Waiting for available storage" private const val DEFAULT_WAITING_FOR_STORAGE = "Waiting for available storage"
private const val DEFAULT_DOWNLOADS = "Downloads" private const val DEFAULT_DOWNLOADS = "Downloads"
private const val DEFAULT_CANCEL = "Cancel" private const val DEFAULT_CANCEL = "Cancel"
private const val TAG = "DownloadServiceHost"
} }
+2
View File
@@ -78,6 +78,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
liveDownloadItemPart.progress = 100 liveDownloadItemPart.progress = 100
liveDownloadItemPart.completed = true liveDownloadItemPart.completed = true
} }
AbsLogger.info(message: "Download completed for \(liveDownloadItemPart.filename ?? partId)")
do { do {
// Move the downloaded file into place // Move the downloaded file into place
@@ -89,6 +90,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
try realm.write { try realm.write {
liveDownloadItemPart.moved = true liveDownloadItemPart.moved = true
} }
AbsLogger.info(message: "Move completed for \(liveDownloadItemPart.filename ?? partId) to \(destinationUrl.path)")
} catch { } catch {
try realm.write { try realm.write {
liveDownloadItemPart.failed = true liveDownloadItemPart.failed = true
+1 -2
View File
@@ -4,7 +4,7 @@
<div v-if="!downloadItemParts.length" class="py-6 text-center text-lg">No download item parts</div> <div v-if="!downloadItemParts.length" class="py-6 text-center text-lg">No download item parts</div>
<template v-for="(itemPart, num) in downloadItemParts"> <template v-for="(itemPart, num) in downloadItemParts">
<div :key="itemPart.id" class="w-full"> <div :key="`${itemPart.downloadItemId}-${itemPart.id}`" class="w-full">
<div class="flex"> <div class="flex">
<div class="w-14"> <div class="w-14">
<span v-if="itemPart.completed" class="material-symbols text-success">check_circle</span> <span v-if="itemPart.completed" class="material-symbols text-success">check_circle</span>
@@ -40,4 +40,3 @@ export default {
beforeDestroy() {} beforeDestroy() {}
} }
</script> </script>