feat: partial download support and cleanup for incomplete files

This commit is contained in:
tapframe 2026-04-12 19:33:08 +05:30
parent 0053d3ffaa
commit b2de11a703
5 changed files with 170 additions and 37 deletions

View file

@ -51,26 +51,61 @@ internal actual object DownloadsPlatformDownloader {
val downloadsDir = File(context.filesDir, "downloads").apply { mkdirs() }
val destination = File(downloadsDir, request.destinationFileName)
val tempFile = File(downloadsDir, "${request.destinationFileName}.part")
if (tempFile.exists()) tempFile.delete()
val requestBuilder = Request.Builder().url(request.sourceUrl)
request.sourceHeaders.forEach { (key, value) ->
requestBuilder.header(key, value)
}
val httpRequest = requestBuilder.get().build()
call = downloadHttpClient.newCall(httpRequest)
try {
call?.execute()?.use { response ->
var resumeFromBytes = tempFile.takeIf { it.exists() }?.length()?.coerceAtLeast(0L) ?: 0L
fun buildRequest(rangeStart: Long?): Request {
val requestBuilder = Request.Builder().url(request.sourceUrl)
request.sourceHeaders.forEach { (key, value) ->
requestBuilder.header(key, value)
}
if (rangeStart != null && rangeStart > 0L) {
requestBuilder.header("Range", "bytes=$rangeStart-")
}
return requestBuilder.get().build()
}
var attemptedRangeRequest = resumeFromBytes > 0L
var httpRequest = buildRequest(if (attemptedRangeRequest) resumeFromBytes else null)
call = downloadHttpClient.newCall(httpRequest)
var response = call?.execute() ?: error("Download request failed")
if (attemptedRangeRequest && response.code == 416) {
response.close()
tempFile.delete()
resumeFromBytes = 0L
attemptedRangeRequest = false
httpRequest = buildRequest(null)
call = downloadHttpClient.newCall(httpRequest)
response = call?.execute() ?: error("Download request failed")
}
response.use { response ->
if (!response.isSuccessful) {
error("Request failed with HTTP ${response.code}")
}
val isPartialResume = attemptedRangeRequest && response.code == 206 && resumeFromBytes > 0L
val appendToTemp = isPartialResume
val startingBytes = if (appendToTemp) resumeFromBytes else 0L
if (!appendToTemp && tempFile.exists()) {
tempFile.delete()
}
val body = response.body ?: error("Empty response body")
val totalBytes = body.contentLength().takeIf { it > 0L }
var downloadedBytes = 0L
val totalBytes = resolveTotalBytes(
startingBytes = startingBytes,
isPartialResume = isPartialResume,
contentRangeHeader = response.header("Content-Range"),
contentLength = body.contentLength().takeIf { it > 0L },
)
var downloadedBytes = startingBytes
onProgress(downloadedBytes, totalBytes)
body.byteStream().use { input ->
FileOutputStream(tempFile, false).use { output ->
FileOutputStream(tempFile, appendToTemp).use { output ->
val buffer = ByteArray(16 * 1024)
while (true) {
ensureActive()
@ -95,10 +130,7 @@ internal actual object DownloadsPlatformDownloader {
val finalSize = destination.length()
onSuccess(destination.toURI().toString(), totalBytes ?: finalSize)
}
} catch (_: CancellationException) {
tempFile.delete()
} catch (error: Throwable) {
tempFile.delete()
onFailure(error.message ?: "Download failed")
}
}
@ -115,6 +147,14 @@ internal actual object DownloadsPlatformDownloader {
val file = localFileUri.toLocalFileOrNull() ?: return false
return runCatching { file.delete() }.getOrDefault(false)
}
actual fun removePartialFile(destinationFileName: String): Boolean {
val context = appContext ?: return false
val downloadsDir = File(context.filesDir, "downloads")
val tempFile = File(downloadsDir, "$destinationFileName.part")
if (!tempFile.exists()) return true
return runCatching { tempFile.delete() }.getOrDefault(false)
}
}
private class AndroidDownloadsTaskHandle(
@ -134,3 +174,28 @@ private fun String.toLocalFileOrNull(): File? {
}
}.getOrNull()
}
private fun resolveTotalBytes(
startingBytes: Long,
isPartialResume: Boolean,
contentRangeHeader: String?,
contentLength: Long?,
): Long? {
parseContentRangeTotal(contentRangeHeader)?.let { return it }
val normalizedLength = contentLength?.takeIf { it > 0L } ?: return null
return if (isPartialResume && startingBytes > 0L) {
startingBytes + normalizedLength
} else {
normalizedLength
}
}
private fun parseContentRangeTotal(headerValue: String?): Long? {
val value = headerValue?.trim().orEmpty()
if (value.isBlank()) return null
val slashIndex = value.lastIndexOf('/')
if (slashIndex == -1 || slashIndex == value.lastIndex) return null
val totalPart = value.substring(slashIndex + 1).trim()
if (totalPart == "*") return null
return totalPart.toLongOrNull()?.takeIf { it > 0L }
}

View file

@ -19,4 +19,6 @@ internal expect object DownloadsPlatformDownloader {
): DownloadsTaskHandle
fun removeFile(localFileUri: String?): Boolean
fun removePartialFile(destinationFileName: String): Boolean
}

View file

@ -115,6 +115,7 @@ object DownloadsRepository {
replacedExisting = true
activeHandles.remove(existing.id)?.cancel()
DownloadsPlatformDownloader.removeFile(existing.localFileUri)
DownloadsPlatformDownloader.removePartialFile(existing.fileName)
currentItems.removeAll { it.id == existing.id }
}
@ -194,8 +195,6 @@ object DownloadsRepository {
val reset = item.copy(
status = DownloadStatus.Downloading,
downloadedBytes = 0L,
totalBytes = null,
errorMessage = null,
localFileUri = null,
updatedAtEpochMs = DownloadsClock.nowEpochMs(),
@ -216,6 +215,7 @@ object DownloadsRepository {
activeHandles.remove(downloadId)?.cancel()
DownloadsPlatformDownloader.removeFile(item.localFileUri)
DownloadsPlatformDownloader.removePartialFile(item.fileName)
publish(_uiState.value.items.filterNot { it.id == downloadId })
persist()
@ -241,7 +241,6 @@ object DownloadsRepository {
item
}
}
.sortedByDescending { it.updatedAtEpochMs }
_uiState.value = DownloadsUiState(normalized)
notifyLiveStatusPlatform()
@ -332,7 +331,7 @@ object DownloadsRepository {
private fun publish(items: List<DownloadItem>) {
_uiState.value = DownloadsUiState(
items = items.sortedByDescending { it.updatedAtEpochMs },
items = items,
)
notifyLiveStatusPlatform()
}
@ -378,7 +377,7 @@ private object DownloadsCodec {
fun encodeItems(items: Collection<DownloadItem>): String =
json.encodeToString(
StoredDownloadsPayload(
items = items.toList().sortedByDescending { it.updatedAtEpochMs },
items = items.toList(),
),
)
}

View file

@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Pause
@ -116,8 +117,10 @@ private fun LazyListScope.downloadsRootContent(
item {
SectionTitle("ACTIVE")
}
items(activeItems.size) { index ->
val item = activeItems[index]
items(
items = activeItems,
key = { it.id },
) { item ->
DownloadRow(
item = item,
onOpen = { onOpenDownload(item) },
@ -133,8 +136,10 @@ private fun LazyListScope.downloadsRootContent(
item {
SectionTitle("MOVIES")
}
items(completedMovies.size) { index ->
val item = completedMovies[index]
items(
items = completedMovies,
key = { it.id },
) { item ->
DownloadRow(
item = item,
onOpen = { onOpenDownload(item) },
@ -150,8 +155,10 @@ private fun LazyListScope.downloadsRootContent(
item {
SectionTitle("SHOWS")
}
items(completedShows.size) { index ->
val (item, episodes) = completedShows[index]
items(
items = completedShows,
key = { (item, _) -> item.parentMetaId },
) { (item, episodes) ->
Surface(
modifier = Modifier
.fillMaxWidth()
@ -262,8 +269,10 @@ private fun LazyListScope.downloadsShowContent(
.thenByDescending { it.updatedAtEpochMs },
)
items(sortedEpisodes.size) { index ->
val item = sortedEpisodes[index]
items(
items = sortedEpisodes,
key = { it.id },
) { item ->
DownloadRow(
item = item,
onOpen = { onOpenDownload(item) },

View file

@ -52,24 +52,52 @@ internal actual object DownloadsPlatformDownloader {
val destinationPath = "$downloadsDirectory/${request.destinationFileName}"
val tempPath = "$downloadsDirectory/${request.destinationFileName}.part"
removePathIfExists(tempPath)
try {
val response = downloadHttpClient.get(request.sourceUrl) {
var resumeFromBytes = fileSizeOrNull(tempPath)?.coerceAtLeast(0L) ?: 0L
suspend fun performRequest(rangeStart: Long?) = downloadHttpClient.get(request.sourceUrl) {
request.sourceHeaders.forEach { (key, value) ->
header(key, value)
}
if (rangeStart != null && rangeStart > 0L) {
header("Range", "bytes=$rangeStart-")
}
}
var attemptedRangeRequest = resumeFromBytes > 0L
var response = performRequest(if (attemptedRangeRequest) resumeFromBytes else null)
if (attemptedRangeRequest && response.status.value == 416) {
removePathIfExists(tempPath)
resumeFromBytes = 0L
attemptedRangeRequest = false
response = performRequest(null)
}
if (!response.status.isSuccess()) {
error("Request failed with HTTP ${response.status.value}")
}
val totalBytes = response.headers["Content-Length"]?.toLongOrNull()?.takeIf { it > 0L }
val isPartialResume = attemptedRangeRequest && response.status.value == 206 && resumeFromBytes > 0L
val appendToTemp = isPartialResume
val startingBytes = if (appendToTemp) resumeFromBytes else 0L
if (!appendToTemp) {
removePathIfExists(tempPath)
}
val totalBytes = resolveTotalBytes(
startingBytes = startingBytes,
isPartialResume = isPartialResume,
contentRangeHeader = response.headers["Content-Range"],
contentLength = response.headers["Content-Length"]?.toLongOrNull()?.takeIf { it > 0L },
)
val channel = response.bodyAsChannel()
val wrote = writeChannelToFile(
channel = channel,
path = tempPath,
append = appendToTemp,
initialDownloadedBytes = startingBytes,
totalBytes = totalBytes,
onProgress = onProgress,
)
@ -90,10 +118,7 @@ internal actual object DownloadsPlatformDownloader {
val localFileUri = NSURL.fileURLWithPath(destinationPath).absoluteString ?: "file://$destinationPath"
val finalSize = fileSizeOrNull(destinationPath)
onSuccess(localFileUri, totalBytes ?: finalSize)
} catch (_: CancellationException) {
removePathIfExists(tempPath)
} catch (error: Throwable) {
removePathIfExists(tempPath)
onFailure(error.message ?: "Download failed")
}
}
@ -106,6 +131,11 @@ internal actual object DownloadsPlatformDownloader {
val path = localFileUri.toLocalPath() ?: return false
return removePathIfExists(path)
}
actual fun removePartialFile(destinationFileName: String): Boolean {
val tempPath = "${downloadsDirectoryPath()}/$destinationFileName.part"
return removePathIfExists(tempPath)
}
}
private class IosDownloadsTaskHandle(
@ -139,12 +169,15 @@ private fun removePathIfExists(path: String): Boolean {
private suspend fun writeChannelToFile(
channel: ByteReadChannel,
path: String,
append: Boolean,
initialDownloadedBytes: Long,
totalBytes: Long?,
onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
): Boolean {
val file = fopen(path, "wb") ?: return false
val file = fopen(path, if (append) "ab" else "wb") ?: return false
val buffer = ByteArray(16 * 1024)
var downloadedBytes = 0L
var downloadedBytes = initialDownloadedBytes
onProgress(downloadedBytes, totalBytes)
return try {
while (true) {
@ -192,3 +225,28 @@ private fun String.toLocalPath(): String? {
}
return takeIf { it.isNotBlank() }
}
private fun resolveTotalBytes(
startingBytes: Long,
isPartialResume: Boolean,
contentRangeHeader: String?,
contentLength: Long?,
): Long? {
parseContentRangeTotal(contentRangeHeader)?.let { return it }
val normalizedLength = contentLength?.takeIf { it > 0L } ?: return null
return if (isPartialResume && startingBytes > 0L) {
startingBytes + normalizedLength
} else {
normalizedLength
}
}
private fun parseContentRangeTotal(headerValue: String?): Long? {
val value = headerValue?.trim().orEmpty()
if (value.isBlank()) return null
val slashIndex = value.lastIndexOf('/')
if (slashIndex == -1 || slashIndex == value.lastIndex) return null
val totalPart = value.substring(slashIndex + 1).trim()
if (totalPart == "*") return null
return totalPart.toLongOrNull()?.takeIf { it > 0L }
}