Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package com.linroid.ketch.app.ui.dialog

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.linroid.ketch.app.util.formatBytes

@Composable
fun RemoveDownloadDialog(
fileName: String,
totalBytes: Long?,
onDismiss: () -> Unit,
onConfirm: (deleteFiles: Boolean) -> Unit,
) {
var deleteFiles by remember { mutableStateOf(false) }

AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Remove download?") },
text = {
Column {
Text(
text = fileName,
fontWeight = FontWeight.Medium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (totalBytes != null && totalBytes > 0) {
Text(
text = formatBytes(totalBytes),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { deleteFiles = !deleteFiles }
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Checkbox(
checked = deleteFiles,
onCheckedChange = { deleteFiles = it },
)
Text("Also delete downloaded file")
}
}
},
confirmButton = {
TextButton(
onClick = {
onConfirm(deleteFiles)
onDismiss()
},
) {
Text("Remove")
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import com.linroid.ketch.app.ui.common.SpeedLimitPanel
import com.linroid.ketch.app.ui.common.StatusIndicator
import com.linroid.ketch.app.ui.common.TaskSettingsIcon
import com.linroid.ketch.app.ui.common.TaskSettingsPanel
import com.linroid.ketch.app.ui.dialog.RemoveDownloadDialog
import com.linroid.ketch.app.util.extractFilename
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
Expand Down Expand Up @@ -72,6 +73,7 @@ fun DownloadListItem(
state is DownloadState.Queued ||
state is DownloadState.Scheduled
var expanded by remember { mutableStateOf(ExpandedPanel.None) }
var showRemoveDialog by remember { mutableStateOf(false) }

Card(
onClick = {
Expand Down Expand Up @@ -228,7 +230,7 @@ fun DownloadListItem(
}
Spacer(modifier = Modifier.weight(1f))
IconButton(
onClick = { scope.launch { task.remove() } },
onClick = { showRemoveDialog = true },
modifier = Modifier.size(32.dp),
) {
Icon(
Expand All @@ -241,6 +243,22 @@ fun DownloadListItem(
}
}

if (showRemoveDialog) {
val totalBytes = when (val s = state) {
is DownloadState.Downloading -> s.progress.totalBytes
is DownloadState.Paused -> s.progress.totalBytes
else -> null
}
RemoveDownloadDialog(
fileName = fileName,
totalBytes = totalBytes,
onDismiss = { showRemoveDialog = false },
onConfirm = { deleteFiles ->
scope.launch { task.remove(deleteFiles = deleteFiles) }
},
)
}

if (showToggles) {
// Expanded panel below icons
AnimatedContent(
Expand Down
37 changes: 37 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,43 @@ interface KetchApi {
}
```

`KetchApi.download(...)` returns a `DownloadTask` for controlling an
individual download. Tasks expose reactive state and per-task actions:

```kotlin
interface DownloadTask {
val taskId: String
val request: DownloadRequest
val state: StateFlow<DownloadState>
val segments: StateFlow<List<Segment>>

suspend fun pause()
suspend fun resume(destination: Destination? = null)
suspend fun cancel()
suspend fun setSpeedLimit(limit: SpeedLimit)
suspend fun setPriority(priority: DownloadPriority)
suspend fun setConnections(connections: Int)
suspend fun reschedule(
schedule: DownloadSchedule,
conditions: List<DownloadCondition> = emptyList(),
)

/**
* Cancels the download and removes it from the task list.
*
* @param deleteFiles when `true`, also delete the data this task
* wrote to disk (partial bytes, completed file, or a torrent's
* save path). Deletion is best-effort — failures are logged
* and do not prevent the task record from being removed.
* Defaults to `false`.
*/
suspend fun remove(deleteFiles: Boolean = false)

/** Suspends until the task reaches a terminal state. */
suspend fun await(): Result<String>
}
```

### `library:core`

The in-process download engine. Depends on an `HttpEngine` interface (no HTTP client dependency):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,13 @@ interface DownloadTask {

/**
* Cancels the download and removes it from the task store and tasks list.
*
* @param deleteFiles when `true`, also delete the downloaded data
* (partial or completed). Deletion is best-effort: failures are
* logged and do not prevent the task record from being removed.
* Defaults to `false` for backward compatibility.
*/
suspend fun remove()
suspend fun remove(deleteFiles: Boolean = false)

/**
* Suspends until the download reaches a terminal state.
Expand Down
14 changes: 12 additions & 2 deletions library/core/src/commonMain/kotlin/com/linroid/ketch/core/Ketch.kt
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import com.linroid.ketch.core.task.TaskHandle
import com.linroid.ketch.core.task.TaskRecord
import com.linroid.ketch.core.task.TaskState
import com.linroid.ketch.core.task.TaskStore
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
Expand Down Expand Up @@ -226,12 +227,21 @@ class Ketch(
coordinator.cancel(handle)
}

override suspend fun remove(handle: TaskHandle) {
override suspend fun remove(handle: TaskHandle, deleteFiles: Boolean) {
val taskId = handle.taskId
log.i { "Removing task: taskId=$taskId" }
log.i { "Removing task: taskId=$taskId, deleteFiles=$deleteFiles" }
scheduler.cancel(taskId)
queue.dequeue(taskId)
coordinator.cancel(handle)
if (deleteFiles) {
try {
coordinator.cleanup(handle)
Comment thread
linroid marked this conversation as resolved.
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
log.w(e) { "Cleanup failed for taskId=$taskId" }
}
}
taskStore.remove(taskId)
tasksMutex.withLock {
_tasks.value = _tasks.value.filter { it.taskId != taskId }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ import com.linroid.ketch.api.DownloadConfig
import com.linroid.ketch.api.log.KetchLogger
import com.linroid.ketch.core.KetchDispatchers
import com.linroid.ketch.core.file.FileNameResolver
import com.linroid.ketch.core.file.NoOpFileAccessor
import com.linroid.ketch.core.file.createFileAccessor
import com.linroid.ketch.core.task.TaskHandle
import com.linroid.ketch.core.task.TaskState
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
Expand Down Expand Up @@ -142,11 +145,17 @@ internal class DownloadCoordinator(
suspend fun cancel(handle: TaskHandle) {
val taskId = handle.taskId
log.i { "Canceling download for taskId=$taskId" }
mutex.withLock {
val job = mutex.withLock {
val entry = activeDownloads[taskId]
entry?.job?.cancel()
activeDownloads.remove(taskId)
entry?.job
}
// Await the job's finally chain (including FileAccessor.close)
// before returning, so callers can safely follow up with file
// cleanup. Joining must happen outside the mutex because the
// job's own finally re-acquires it to clean up activeDownloads.
job?.join()
handle.mutableState.value = DownloadState.Canceled
handle.record.update {
it.copy(
Expand Down Expand Up @@ -232,6 +241,56 @@ internal class DownloadCoordinator(
)
}

/**
* Deletes any data produced for the given task by dispatching to its
* [DownloadSource.cleanup]. The caller must have cancelled the active
* download (if any) before invoking this. No-op if the task has no
* recorded sourceType or outputPath (nothing to clean up).
*/
suspend fun cleanup(handle: TaskHandle) {
val record = handle.record.value
val sourceType = record.sourceType
val outputPath = record.outputPath
if (sourceType == null || outputPath == null) {
log.d {
"Skipping cleanup for taskId=${handle.taskId} " +
"(sourceType=$sourceType, outputPath=$outputPath)"
}
return
}
val source = try {
sourceResolver.resolveByType(sourceType)
} catch (e: KetchError) {
log.w(e) {
"Skipping cleanup for taskId=${handle.taskId}: " +
"unknown source type '$sourceType'"
}
return
}
val fa = if (source.managesOwnFileIo) {
NoOpFileAccessor
} else {
createFileAccessor(outputPath, dispatchers.io)
}
val ctx = DownloadContext(
taskId = handle.taskId,
url = handle.request.url,
request = handle.request,
fileAccessor = fa,
segments = MutableStateFlow(handle.mutableSegments.value),
onProgress = { _, _ -> },
throttle = { _ -> },
headers = handle.request.headers,
)
try {
source.cleanup(ctx, record.sourceResumeState)
} finally {
if (!source.managesOwnFileIo) {
fa.close()
}
}
}

fun close() {
scope.cancel()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.linroid.ketch.core.engine

import com.linroid.ketch.api.ResolvedSource
import kotlinx.coroutines.CancellationException

/**
* Abstraction for pluggable download source types.
Expand Down Expand Up @@ -92,4 +93,37 @@ interface DownloadSource {
suspend fun updateResumeState(
context: DownloadContext,
): SourceResumeState? = null

/**
* Deletes any data this source wrote for the given task. Called by
* the engine when a task is removed with `deleteFiles = true`,
* after the active download (if any) has been cancelled.
*
* Best-effort: implementations should log and swallow non-fatal
* errors rather than throwing. [CancellationException] must still
* propagate.
*
* The default implementation deletes [DownloadContext.fileAccessor],
* which is the right behavior for sources that write to a single
* output file. Sources with `managesOwnFileIo = true` (e.g.
* torrents) should override this and use [resumeState] to locate
* their internal handle (such as an info hash) for cleanup.
*
* @param context the download context, with a [DownloadContext.fileAccessor]
* pointing at the persisted output path
* @param resumeState the source-specific resume state persisted in the
* task record, or `null` if the task never produced one
*/
suspend fun cleanup(
context: DownloadContext,
resumeState: SourceResumeState?,
) {
try {
context.fileAccessor.delete()
} catch (e: CancellationException) {
throw e
} catch (_: Throwable) {
// best-effort; caller is responsible for logging
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ internal class RealDownloadTask(
controller.reschedule(this, schedule, conditions)
}

override suspend fun remove() {
controller.remove(this)
override suspend fun remove(deleteFiles: Boolean) {
controller.remove(this, deleteFiles)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ internal interface TaskController {
suspend fun pause(taskId: String)
suspend fun resume(handle: TaskHandle, destination: Destination? = null)
suspend fun cancel(handle: TaskHandle)
suspend fun remove(handle: TaskHandle)
suspend fun remove(handle: TaskHandle, deleteFiles: Boolean)
suspend fun setSpeedLimit(taskId: String, limit: SpeedLimit)
suspend fun setConnections(taskId: String, connections: Int)
suspend fun setPriority(taskId: String, priority: DownloadPriority)
Expand Down
Loading
Loading