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
2 changes: 1 addition & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ When working on this project:
- Avoid platform-specific APIs in common code
- Use dependency injection for pluggable components (HttpEngine, TaskStore, Logger)
- Prefer composition over inheritance
- Package structure: root (KDown, DownloadConfig, DownloadRequest, DownloadProgress, DownloadState), `task/` (DownloadTask, TaskStore, InMemoryTaskStore, TaskRecord, TaskState), `segment/` (Segment, SegmentCalculator, SegmentDownloader), `engine/` (HttpEngine, DownloadCoordinator, RangeSupportDetector, ServerInfo), `file/` (FileAccessor, FileNameResolver, DefaultFileNameResolver, PathSerializer), `download/` (TimeProvider), `log/` (Logger, KDownLogger), `error/` (KDownError)
- Package structure: root (KDown, DownloadConfig, DownloadRequest, DownloadProgress, DownloadState), `task/` (DownloadTask, TaskStore, InMemoryTaskStore, TaskRecord, TaskState), `segment/` (Segment, SegmentCalculator, SegmentDownloader), `engine/` (HttpEngine, DownloadCoordinator, RangeSupportDetector, ServerInfo), `file/` (FileAccessor, FileNameResolver, DefaultFileNameResolver, PathSerializer), `log/` (Logger, KDownLogger), `error/` (KDownError)

### Testing
- Add unit tests for new features in `commonTest`
Expand Down
4 changes: 2 additions & 2 deletions library/core/src/commonMain/kotlin/com/linroid/kdown/KDown.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.linroid.kdown

import com.linroid.kdown.download.currentTimeMillis
import com.linroid.kdown.engine.DownloadCoordinator
import com.linroid.kdown.segment.Segment
import com.linroid.kdown.engine.HttpEngine
Expand All @@ -25,6 +24,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.io.files.Path
import kotlin.time.Clock
import kotlin.uuid.Uuid

class KDown(
Expand Down Expand Up @@ -63,7 +63,7 @@ class KDown(
*/
suspend fun download(request: DownloadRequest): DownloadTask {
val taskId = Uuid.random().toString()
val now = currentTimeMillis()
val now = Clock.System.now()
KDownLogger.i("KDown") {
"Starting download: taskId=$taskId, url=${request.url}, " +
"connections=${request.connections}"
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import com.linroid.kdown.DownloadConfig
import com.linroid.kdown.DownloadProgress
import com.linroid.kdown.DownloadRequest
import com.linroid.kdown.DownloadState
import com.linroid.kdown.download.currentTimeMillis
import com.linroid.kdown.error.KDownError
import com.linroid.kdown.file.FileAccessor
import com.linroid.kdown.file.FileNameResolver
Expand All @@ -31,6 +30,8 @@ import kotlinx.coroutines.withContext
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import kotlin.collections.sumOf
import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds

internal class DownloadCoordinator(
private val httpEngine: HttpEngine,
Expand Down Expand Up @@ -63,7 +64,7 @@ internal class DownloadCoordinator(
Path(request.directory, it)
} ?: request.directory

val now = currentTimeMillis()
val now = Clock.System.now()
taskStore.save(
TaskRecord(
taskId = taskId,
Expand Down Expand Up @@ -121,7 +122,7 @@ internal class DownloadCoordinator(
updateTaskRecord(record.taskId) {
it.copy(
state = TaskState.PENDING,
updatedAt = currentTimeMillis()
updatedAt = Clock.System.now()
)
}

Expand Down Expand Up @@ -197,7 +198,7 @@ internal class DownloadCoordinator(

segmentsFlow.value = segments

val now = currentTimeMillis()
val now = Clock.System.now()
updateTaskRecord(taskId) {
it.copy(
destPath = destPath,
Expand Down Expand Up @@ -254,7 +255,7 @@ internal class DownloadCoordinator(
state = TaskState.COMPLETED,
downloadedBytes = totalBytes,
segments = null,
updatedAt = currentTimeMillis()
updatedAt = Clock.System.now()
)
}

Expand Down Expand Up @@ -334,7 +335,7 @@ internal class DownloadCoordinator(
activeDownloads[taskId]?.segmentProgress = segmentProgress
}

var lastProgressUpdate = currentTimeMillis()
var lastProgressUpdate = Clock.System.now()
val progressMutex = Mutex()

val incompleteSegments = segments.filter { !it.isComplete }
Expand All @@ -349,9 +350,9 @@ internal class DownloadCoordinator(
}

suspend fun updateProgress() {
val now = currentTimeMillis()
val now = Clock.System.now()
progressMutex.withLock {
if (now - lastProgressUpdate >= config.progressUpdateIntervalMs) {
if (now - lastProgressUpdate >= config.progressUpdateIntervalMs.milliseconds) {
val snapshot = currentSegments()
val downloaded = snapshot.sumOf { it.downloadedBytes }
stateFlow.value = DownloadState.Downloading(
Expand Down Expand Up @@ -381,7 +382,7 @@ internal class DownloadCoordinator(
it.copy(
segments = snapshot,
downloadedBytes = snapshot.sumOf { s -> s.downloadedBytes },
updatedAt = currentTimeMillis()
updatedAt = Clock.System.now()
)
}
}
Expand Down Expand Up @@ -450,7 +451,7 @@ internal class DownloadCoordinator(
KDownLogger.d("Coordinator") {
"Saving pause state for taskId=$taskId"
}
val now = currentTimeMillis()
val now = Clock.System.now()
val progress = active.segmentProgress
val updatedSegments = if (progress != null) {
segments.mapIndexed { i, seg ->
Expand Down Expand Up @@ -523,7 +524,7 @@ internal class DownloadCoordinator(
updateTaskRecord(taskId) {
it.copy(
state = TaskState.DOWNLOADING,
updatedAt = currentTimeMillis()
updatedAt = Clock.System.now()
)
}

Expand Down Expand Up @@ -620,7 +621,7 @@ internal class DownloadCoordinator(
it.copy(
segments = validatedSegments,
downloadedBytes = validatedSegments.sumOf { s -> s.downloadedBytes },
updatedAt = currentTimeMillis()
updatedAt = Clock.System.now()
)
}
}
Expand All @@ -644,7 +645,7 @@ internal class DownloadCoordinator(
state = TaskState.COMPLETED,
downloadedBytes = taskRecord.totalBytes,
segments = null,
updatedAt = currentTimeMillis()
updatedAt = Clock.System.now()
)
}

Expand Down Expand Up @@ -712,7 +713,7 @@ internal class DownloadCoordinator(
it.copy(
state = TaskState.CANCELED,
segments = null,
updatedAt = currentTimeMillis()
updatedAt = Clock.System.now()
)
}
}
Expand All @@ -732,7 +733,7 @@ internal class DownloadCoordinator(
it.copy(
state = state,
errorMessage = errorMessage,
updatedAt = currentTimeMillis()
updatedAt = Clock.System.now()
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,21 @@ import com.linroid.kdown.segment.Segment
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.io.files.Path
import kotlin.time.Instant

/**
* Represents a download task with reactive state and control methods.
*
* @property taskId Unique identifier for this download task
* @property request The download request configuration
* @property createdAt Timestamp when the task was created (milliseconds since epoch)
* @property createdAt Timestamp when the task was created
* @property state Observable download state (Pending, Downloading, Paused, Completed, Failed, Canceled)
* @property segments Observable list of download segments with their progress
*/
class DownloadTask internal constructor(
val taskId: String,
val request: DownloadRequest,
val createdAt: Long,
val createdAt: Instant,
val state: StateFlow<DownloadState>,
val segments: StateFlow<List<Segment>>,
private val pauseAction: suspend () -> Unit,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.linroid.kdown.file.PathSerializer
import com.linroid.kdown.segment.Segment
import kotlinx.io.files.Path
import kotlinx.serialization.Serializable
import kotlin.time.Instant

/**
* A persistent record of a download task. Contains all information needed
Expand All @@ -27,6 +28,6 @@ data class TaskRecord(
val etag: String? = null,
val lastModified: String? = null,
val segments: List<Segment>? = null,
val createdAt: Long,
val updatedAt: Long
val createdAt: Instant,
val updatedAt: Instant
)
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.linroid.kdown.DownloadRequest
import kotlinx.coroutines.test.runTest
import kotlinx.io.files.Path
import kotlin.test.Test
import kotlin.time.Instant
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
Expand All @@ -25,8 +26,8 @@ class InMemoryTaskStoreTest {
state = state,
totalBytes = 1000,
downloadedBytes = 0,
createdAt = 1000L,
updatedAt = 1000L
createdAt = Instant.fromEpochMilliseconds(1000),
updatedAt = Instant.fromEpochMilliseconds(1000)
)

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.time.Instant

class TaskRecordTest {

Expand All @@ -29,8 +30,8 @@ class TaskRecordTest {
taskId = "test-1",
request = defaultRequest(),
destPath = Path("/tmp/file.bin"),
createdAt = 1000L,
updatedAt = 1000L
createdAt = Instant.fromEpochMilliseconds(1000),
updatedAt = Instant.fromEpochMilliseconds(1000)
)
assertEquals(1, record.request.connections)
assertEquals(emptyMap(), record.request.headers)
Expand All @@ -57,8 +58,8 @@ class TaskRecordTest {
totalBytes = 2048,
downloadedBytes = 1024,
errorMessage = null,
createdAt = 1000L,
updatedAt = 2000L
createdAt = Instant.fromEpochMilliseconds(1000),
updatedAt = Instant.fromEpochMilliseconds(2000)
)

val serialized = json.encodeToString(
Expand Down Expand Up @@ -95,8 +96,8 @@ class TaskRecordTest {
destPath = Path("/tmp/file.bin"),
state = TaskState.FAILED,
errorMessage = "Network timeout",
createdAt = 1000L,
updatedAt = 2000L
createdAt = Instant.fromEpochMilliseconds(1000),
updatedAt = Instant.fromEpochMilliseconds(2000)
)

val serialized = json.encodeToString(
Expand All @@ -112,29 +113,31 @@ class TaskRecordTest {

@Test
fun copy_preservesValues() {
val created = Instant.fromEpochMilliseconds(1000)
val original = TaskRecord(
taskId = "test-1",
request = defaultRequest(),
destPath = Path("/tmp/file.bin"),
state = TaskState.DOWNLOADING,
totalBytes = 1000,
downloadedBytes = 500,
createdAt = 1000L,
updatedAt = 1000L
createdAt = created,
updatedAt = Instant.fromEpochMilliseconds(1000)
)

val newUpdated = Instant.fromEpochMilliseconds(2000)
val updated = original.copy(
state = TaskState.PAUSED,
downloadedBytes = 600,
updatedAt = 2000L
updatedAt = newUpdated
)

assertEquals("test-1", updated.taskId)
assertEquals("https://example.com/file.bin", updated.request.url)
assertEquals(TaskState.PAUSED, updated.state)
assertEquals(600, updated.downloadedBytes)
assertEquals(1000L, updated.createdAt)
assertEquals(2000L, updated.updatedAt)
assertEquals(created, updated.createdAt)
assertEquals(newUpdated, updated.updatedAt)
}

@Test
Expand All @@ -148,8 +151,8 @@ class TaskRecordTest {
acceptRanges = true,
etag = "\"abc123\"",
lastModified = "Wed, 21 Oct 2023 07:28:00 GMT",
createdAt = 1000L,
updatedAt = 2000L
createdAt = Instant.fromEpochMilliseconds(1000),
updatedAt = Instant.fromEpochMilliseconds(2000)
)

val serialized = json.encodeToString(
Expand Down Expand Up @@ -181,8 +184,8 @@ class TaskRecordTest {
totalBytes = 1000,
downloadedBytes = 500,
segments = segments,
createdAt = 1000L,
updatedAt = 2000L
createdAt = Instant.fromEpochMilliseconds(1000),
updatedAt = Instant.fromEpochMilliseconds(2000)
)

val serialized = json.encodeToString(
Expand All @@ -200,6 +203,7 @@ class TaskRecordTest {

@Test
fun deserialization_withoutSegments_defaultsToNull() {
val epoch = Instant.fromEpochMilliseconds(0)
val jsonStr = """
{
"taskId": "t1",
Expand All @@ -214,8 +218,8 @@ class TaskRecordTest {
"state": "COMPLETED",
"totalBytes": 1000,
"downloadedBytes": 1000,
"createdAt": 0,
"updatedAt": 0
"createdAt": "$epoch",
"updatedAt": "$epoch"
}
""".trimIndent()
val record = json.decodeFromString<TaskRecord>(jsonStr)
Expand All @@ -224,6 +228,7 @@ class TaskRecordTest {

@Test
fun deserialization_withoutServerInfoFields_defaultsToNull() {
val epoch = Instant.fromEpochMilliseconds(0)
val jsonStr = """
{
"taskId": "t1",
Expand All @@ -238,8 +243,8 @@ class TaskRecordTest {
"state": "PENDING",
"totalBytes": 100,
"downloadedBytes": 0,
"createdAt": 0,
"updatedAt": 0
"createdAt": "$epoch",
"updatedAt": "$epoch"
}
""".trimIndent()
val record = json.decodeFromString<TaskRecord>(jsonStr)
Expand Down
Loading
Loading