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
313 changes: 147 additions & 166 deletions .claude/CLAUDE.md

Large diffs are not rendered by default.

280 changes: 146 additions & 134 deletions README.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
package com.linroid.kdown.api

/**
* Snapshot of download progress aggregated across all segments.
*
* @property downloadedBytes total number of bytes received so far
* @property totalBytes expected file size in bytes, or 0 if unknown
* @property bytesPerSecond current download speed in bytes per second
*/
data class DownloadProgress(
val downloadedBytes: Long,
val totalBytes: Long,
val bytesPerSecond: Long = 0
) {
/** Fraction complete in the range `0f..1f`, or `0f` if [totalBytes] is unknown. */
val percent: Float
get() = if (totalBytes > 0) downloadedBytes.toFloat() / totalBytes else 0f

/** `true` when [downloadedBytes] has reached or exceeded [totalBytes]. */
val isComplete: Boolean
get() = totalBytes > 0 && downloadedBytes >= totalBytes
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,54 @@
package com.linroid.kdown.api

/**
* Represents the lifecycle state of a download task.
*
* State transitions follow this general flow:
* ```
* Idle -> Scheduled -> Queued -> Pending -> Downloading -> Completed
* | |
* v v
* Canceled Paused -> Downloading
* |
* v
* Failed
* ```
*
* @see DownloadTask.state
*/
sealed class DownloadState {
/** Initial state before the task has been submitted. */
data object Idle : DownloadState()

/** Waiting for a [DownloadSchedule] trigger or [DownloadCondition]s. */
data class Scheduled(val schedule: DownloadSchedule) : DownloadState()

/** Waiting in the download queue for an available slot. */
data object Queued : DownloadState()

/** Slot acquired; download is about to start. */
data object Pending : DownloadState()

/** Actively downloading. [progress] is updated periodically. */
data class Downloading(val progress: DownloadProgress) : DownloadState()

/** Download paused by the user or preempted by the scheduler. */
data class Paused(val progress: DownloadProgress) : DownloadState()

/** Download finished successfully. [filePath] is the output file. */
data class Completed(val filePath: String) : DownloadState()

/** Download failed with [error]. May be retried if the error is retryable. */
data class Failed(val error: KDownError) : DownloadState()

/** Download was explicitly canceled. */
data object Canceled : DownloadState()

/** `true` when the task has reached a final state and cannot be resumed. */
val isTerminal: Boolean
get() = this is Completed || this is Failed || this is Canceled

/** `true` when the task is actively using a download slot. */
val isActive: Boolean
get() = this is Pending || this is Downloading
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,13 @@ interface DownloadTask {
val state: StateFlow<DownloadState>
val segments: StateFlow<List<Segment>>

/** Pauses the download, preserving segment progress for later resume. */
suspend fun pause()

/** Resumes a paused or failed download from where it left off. */
suspend fun resume()

/** Cancels the download. This is a terminal action. */
suspend fun cancel()

/**
Expand Down Expand Up @@ -59,6 +64,12 @@ interface DownloadTask {
*/
suspend fun remove()

/**
* Suspends until the download reaches a terminal state.
*
* @return [Result.success] with the output file path on completion,
* or [Result.failure] with a [KDownError] on failure or cancellation
*/
suspend fun await(): Result<String>
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,40 +1,76 @@
package com.linroid.kdown.api

/**
* Sealed hierarchy of all errors that KDown can produce.
*
* Use [isRetryable] to determine whether the operation should be
* retried automatically. Only transient failures ([Network] and
* server-side [Http] 5xx) are considered retryable.
*
* @property message human-readable error description
* @property cause underlying exception, if any
*/
sealed class KDownError(
override val message: String?,
override val cause: Throwable? = null
) : Exception(message, cause) {

/** Connection or timeout failure. Always retryable. */
data class Network(
override val cause: Throwable? = null
) : KDownError("Network error occurred", cause)

/**
* Non-success HTTP status code.
* Retryable only for server errors (5xx).
*
* @property code the HTTP status code
* @property statusMessage optional reason phrase from the server
*/
data class Http(
val code: Int,
val statusMessage: String? = null
) : KDownError("HTTP error $code: $statusMessage")

/** File I/O failure (write, flush, preallocate). Not retryable. */
data class Disk(
override val cause: Throwable? = null
) : KDownError("Disk I/O error", cause)

/** Server does not support a required feature (e.g., byte ranges). */
data object Unsupported : KDownError("Operation not supported by server")

/**
* Resume validation failed (ETag or Last-Modified mismatch).
*
* @property reason description of what failed validation
*/
data class ValidationFailed(
val reason: String
) : KDownError("Validation failed: $reason")

/** Download was explicitly canceled by the user. */
data object Canceled : KDownError("Download was canceled")

/**
* Error originating from a pluggable [DownloadSource][com.linroid.kdown.core.engine.DownloadSource].
*
* @property sourceType identifier of the source that failed
*/
data class SourceError(
val sourceType: String,
override val cause: Throwable? = null
) : KDownError("Source '$sourceType' error", cause)

/** Catch-all for unexpected errors. Not retryable. */
data class Unknown(
override val cause: Throwable? = null
) : KDownError("Unknown error occurred", cause)

/**
* Whether this error is transient and the download should be retried.
* Only [Network] and [Http] with a 5xx status code are retryable.
*/
val isRetryable: Boolean
get() = when (this) {
is Network -> true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ package com.linroid.kdown.api

import kotlinx.serialization.Serializable

/**
* Version information for a KDown client-backend pair.
*
* @property client version of the client library (e.g., the app)
* @property backend version of the backend (Core or Remote server)
*/
@Serializable
data class KDownVersion(
val client: String,
Expand Down
15 changes: 15 additions & 0 deletions library/api/src/commonMain/kotlin/com/linroid/kdown/api/Segment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,37 @@ package com.linroid.kdown.api

import kotlinx.serialization.Serializable

/**
* Represents a byte-range segment of a download.
*
* A file is split into one or more segments that download concurrently.
* Each segment tracks its own progress independently.
*
* @property index zero-based segment index
* @property start inclusive start byte offset in the file
* @property end inclusive end byte offset in the file
* @property downloadedBytes number of bytes downloaded so far in this segment
*/
@Serializable
data class Segment(
val index: Int,
val start: Long,
val end: Long,
val downloadedBytes: Long = 0
) {
/** Total number of bytes this segment is responsible for. */
val totalBytes: Long
get() = end - start + 1

/** The next byte offset to write at (`start + downloadedBytes`). */
val currentOffset: Long
get() = start + downloadedBytes

/** `true` when [downloadedBytes] has reached [totalBytes]. */
val isComplete: Boolean
get() = downloadedBytes >= totalBytes

/** Number of bytes still to be downloaded. */
val remainingBytes: Long
get() = totalBytes - downloadedBytes
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,40 @@
package com.linroid.kdown.core.engine


/**
* Abstraction over the HTTP layer used by KDown.
*
* The default implementation is backed by Ktor (`library:ktor` module).
* Implement this interface to plug in a different HTTP client.
*/
interface HttpEngine {
/**
* Performs an HTTP HEAD request to retrieve server metadata.
*
* @param url the resource URL
* @param headers additional request headers
* @return server metadata including content length and range support
* @throws com.linroid.kdown.api.KDownError.Network on connection failure
* @throws com.linroid.kdown.api.KDownError.Http on non-success status
*/
suspend fun head(url: String, headers: Map<String, String> = emptyMap()): ServerInfo

/**
* Downloads data from [url] and delivers chunks via [onData].
*
* @param url the resource URL
* @param range byte range to request, or `null` for the entire resource
* @param headers additional request headers
* @param onData callback invoked for each chunk of received bytes
* @throws com.linroid.kdown.api.KDownError.Network on connection failure
* @throws com.linroid.kdown.api.KDownError.Http on non-success status
*/
suspend fun download(
url: String,
range: LongRange?,
headers: Map<String, String> = emptyMap(),
onData: suspend (ByteArray) -> Unit
)

/** Releases underlying resources (e.g., the HTTP client). */
fun close()
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
package com.linroid.kdown.core.engine

/**
* Metadata returned by an HTTP HEAD request.
*
* Used to determine download strategy (segmented vs. single) and
* to validate resume integrity.
*
* @property contentLength total content size in bytes, or `null` if unknown
* @property acceptRanges `true` if the server advertises `Accept-Ranges: bytes`
* @property etag the `ETag` header value, used for resume validation
* @property lastModified the `Last-Modified` header value, used for resume validation
* @property contentDisposition the `Content-Disposition` header, used for file name resolution
*/
data class ServerInfo(
val contentLength: Long?,
val acceptRanges: Boolean,
val etag: String?,
val lastModified: String?,
val contentDisposition: String? = null
) {
/** `true` when the server supports byte-range requests and reports a content length. */
val supportsResume: Boolean
get() = acceptRanges && contentLength != null && contentLength > 0
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,34 @@ package com.linroid.kdown.core.file

import kotlinx.io.files.Path

/**
* Platform-specific random-access file writer.
*
* Each platform provides an actual implementation:
* - **Android/JVM**: `RandomAccessFile` with `Dispatchers.IO`
* - **iOS**: Foundation `NSFileHandle` / `NSFileManager` with `Dispatchers.IO`
* - **WasmJs**: Stub that throws `UnsupportedOperationException` (no file I/O)
*
* Android, JVM, and iOS implementations are thread-safe (protected by a `Mutex`).
*
* @param path the file system path to write to
*/
expect class FileAccessor(path: Path) {
/** Writes [data] starting at the given byte [offset]. */
suspend fun writeAt(offset: Long, data: ByteArray)

/** Flushes buffered writes to disk. */
suspend fun flush()

/** Closes the underlying file handle. */
fun close()

/** Deletes the file from disk. */
suspend fun delete()

/** Returns the current file size in bytes. */
suspend fun size(): Long

/** Pre-allocates [size] bytes on disk to avoid fragmentation. */
suspend fun preallocate(size: Long)
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ import io.ktor.http.isSuccess
import io.ktor.utils.io.readAvailable
import kotlin.coroutines.cancellation.CancellationException

/**
* [HttpEngine] implementation backed by a Ktor [HttpClient].
*
* Uses platform-specific Ktor engines: OkHttp (Android), Darwin (iOS),
* CIO (JVM), and Js (WasmJs/JS).
*
* @param client the Ktor HTTP client to use, or a default client
* with infinite timeouts (suitable for large downloads)
*/
class KtorHttpEngine(
private val client: HttpClient = defaultClient()
) : HttpEngine {
Expand Down
Loading