diff --git a/android/app/build.gradle b/android/app/build.gradle index c9f7acf4..979655cc 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -92,6 +92,7 @@ dependencies { implementation project(':capacitor-cordova-android-plugins') implementation "androidx.core:core-ktx:$androidx_core_ktx_version" + implementation "androidx.work:work-runtime-ktx:2.9.1" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version" diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt index 88ce1737..4698a936 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt @@ -63,6 +63,7 @@ class DownloadItemManager( init { DeviceManager.dbManager.clearLegacyDownloadQueueOnce() + IncompleteDownloadCleanup.cleanupExpired(context) } @Synchronized @@ -83,6 +84,7 @@ class DownloadItemManager( } item.downloadItemParts.forEach { part -> if (part.moved) return@forEach + if (item.terminalFailureAt != null && part.failed) return@forEach part.downloadId = null part.isMoving = false part.failed = false @@ -91,6 +93,7 @@ class DownloadItemManager( part.bytesDownloaded = File(part.destinationPath).takeIf(File::exists)?.length() ?: 0L } downloadItemQueue.add(item) + if (item.terminalFailureAt != null) IncompleteDownloadCleanup.schedule(context, item) clientEventEmitter.onDownloadItem(item) } checkUpdateDownloadQueue() @@ -110,6 +113,8 @@ class DownloadItemManager( @Synchronized fun retryAll() { downloadItemQueue.forEach { item -> + item.terminalFailureAt = null + IncompleteDownloadCleanup.cancel(context, item.id) item.downloadItemParts.filter { it.failed }.forEach { part -> part.failed = false part.completed = false @@ -243,7 +248,9 @@ class DownloadItemManager( 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 } diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/IncompleteDownloadCleanup.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/IncompleteDownloadCleanup.kt new file mode 100644 index 00000000..7d517a8b --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/IncompleteDownloadCleanup.kt @@ -0,0 +1,98 @@ +package com.audiobookshelf.app.managers + +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.documentfile.provider.DocumentFile +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.models.DownloadItem +import java.io.File +import java.util.concurrent.TimeUnit + +/** Removes only terminally failed download items after their retention window has elapsed. */ +object IncompleteDownloadCleanup { + private const val tag = "IncompleteDownloadCleanup" + private const val RETENTION_MS = 24L * 60L * 60L * 1000L + private const val WORK_PREFIX = "incomplete-download-" + + fun schedule(context: Context, item: DownloadItem) { + val failedAt = item.terminalFailureAt ?: return + val delay = (failedAt + RETENTION_MS - System.currentTimeMillis()).coerceAtLeast(0L) + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(delay, TimeUnit.MILLISECONDS) + .build() + WorkManager.getInstance(context).enqueueUniqueWork( + WORK_PREFIX + item.id, ExistingWorkPolicy.REPLACE, request) + } + + fun cancel(context: Context, itemId: String) { + WorkManager.getInstance(context).cancelUniqueWork(WORK_PREFIX + itemId) + } + + /** Called on app/service startup as a catch-up for work delayed by Android or force-stop. */ + fun cleanupExpired(context: Context): Set { + val now = System.currentTimeMillis() + return DeviceManager.dbManager.getDownloadItems() + .filter { item -> isEligible(item, now) } + .map { item -> + deleteItem(context, item) + item.id + } + .toSet() + } + + private fun isEligible(item: DownloadItem, now: Long): Boolean { + val failedAt = item.terminalFailureAt ?: return false + if (now - failedAt < RETENTION_MS) return false + // Do not remove an item while another part is still downloading, waiting, or finalizing. + return item.downloadItemParts.all { part -> + part.moved || (part.failed && !part.isMoving) + } + } + + private fun deleteItem(context: Context, item: DownloadItem) { + item.downloadItemParts.forEach { part -> + deleteAppOwnedFile(context, File(part.destinationPath)) + if (part.isInternalStorage && part.moved) { + deleteAppOwnedFile(context, File(part.finalDestinationPath)) + } else if (!part.isInternalStorage && part.moved) { + // Never infer an arbitrary SAF path during cleanup. The stored document URI is authoritative. + 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) + cancel(context, item.id) + Log.i(tag, "Deleted terminally failed download item ${item.id}") + } + + private fun deleteAppOwnedFile(context: Context, file: File) { + val path = file.absolutePath + val internal = context.filesDir.absolutePath + val external = context.getExternalFilesDir(null)?.absolutePath + if (path.startsWith(internal) || (external != null && path.startsWith(external))) { + if (file.exists() && !file.delete()) Log.w(tag, "Could not delete expired staging file $path") + file.parentFile?.takeIf { it.isDirectory && it.list()?.isEmpty() == true }?.delete() + } else { + Log.w(tag, "Refusing to delete non-app-owned path $path") + } + } +} + +class IncompleteDownloadCleanupWorker(context: Context, params: WorkerParameters) : Worker(context, params) { + override fun doWork(): Result { + DbManager.initialize(applicationContext) + IncompleteDownloadCleanup.cleanupExpired(applicationContext) + return Result.success() + } +} 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 e23ca2d9..af1657b7 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 @@ -6,29 +6,32 @@ import com.audiobookshelf.app.data.MediaType import com.fasterxml.jackson.annotation.JsonIgnore data class DownloadItem( - val id: String, - val libraryItemId:String, - val episodeId:String?, - val userMediaProgress: MediaProgress?, - val serverConnectionConfigId:String, - val serverAddress:String, - val serverUserId:String, - val mediaType: String, - val itemFolderPath:String, - val localFolder: LocalFolder, - val itemTitle: String, - val itemSubfolder: String, - val media: MediaType, - val downloadItemParts: MutableList + val id: String, + val libraryItemId: String, + val episodeId: String?, + val userMediaProgress: MediaProgress?, + val serverConnectionConfigId: String, + val serverAddress: String, + val serverUserId: String, + val mediaType: String, + val itemFolderPath: String, + val localFolder: LocalFolder, + val itemTitle: String, + val itemSubfolder: String, + val media: MediaType, + val downloadItemParts: MutableList, + @JsonIgnore var terminalFailureAt: Long? = null ) { @get:JsonIgnore - val isInternalStorage get() = localFolder.id.startsWith("internal-") + val isInternalStorage + get() = localFolder.id.startsWith("internal-") @get:JsonIgnore - val isDownloadFinished get() = !downloadItemParts.any { !it.completed || it.isMoving || it.failed } + val isDownloadFinished + get() = !downloadItemParts.any { !it.completed || it.isMoving || it.failed } @JsonIgnore - fun getNextDownloadItemParts(limit:Int): MutableList { + fun getNextDownloadItemParts(limit: Int): MutableList { val itemParts = mutableListOf() if (limit == 0) return itemParts