diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts index dcc2a541..be9d4a28 100644 --- a/app/shared/build.gradle.kts +++ b/app/shared/build.gradle.kts @@ -63,12 +63,14 @@ kotlin { androidMain.dependencies { implementation(projects.ai.discover) implementation(projects.library.ftp) + implementation(projects.library.torrent) implementation(libs.compose.uiToolingPreview) implementation(libs.ktor.client.okhttp) implementation(libs.dnssd) } iosMain.dependencies { implementation(projects.library.ftp) + implementation(projects.library.torrent) implementation(projects.library.sqlite) implementation(libs.ktor.client.darwin) implementation(libs.dnssd) @@ -76,6 +78,7 @@ kotlin { jvmMain.dependencies { implementation(projects.ai.discover) implementation(projects.library.ftp) + implementation(projects.library.torrent) implementation(projects.library.sqlite) implementation(libs.kotlinx.coroutinesSwing) implementation(libs.ktor.client.cio) diff --git a/app/shared/src/androidMain/kotlin/com/linroid/ketch/app/instance/PlatformSources.android.kt b/app/shared/src/androidMain/kotlin/com/linroid/ketch/app/instance/PlatformSources.android.kt index 07aef20f..85853d09 100644 --- a/app/shared/src/androidMain/kotlin/com/linroid/ketch/app/instance/PlatformSources.android.kt +++ b/app/shared/src/androidMain/kotlin/com/linroid/ketch/app/instance/PlatformSources.android.kt @@ -2,6 +2,7 @@ package com.linroid.ketch.app.instance import com.linroid.ketch.core.engine.DownloadSource import com.linroid.ketch.ftp.FtpDownloadSource +import com.linroid.ketch.torrent.TorrentDownloadSource internal actual fun platformAdditionalSources(): List = - listOf(FtpDownloadSource()) + listOf(FtpDownloadSource(), TorrentDownloadSource()) diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/state/AppState.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/state/AppState.kt index d2b0106d..ec00de4e 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/state/AppState.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/state/AppState.kt @@ -185,6 +185,7 @@ class AppState( priority: DownloadPriority, schedule: DownloadSchedule = DownloadSchedule.Immediate, resolvedUrl: ResolvedSource? = null, + selectedFileIds: Set = emptySet(), ) { scope.launch { runCatching { @@ -196,6 +197,7 @@ class AppState( priority = priority, schedule = schedule, resolvedSource = resolvedUrl, + selectedFileIds = selectedFileIds, ) activeApi.value.download(request) }.onFailure { e -> diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/AppShell.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/AppShell.kt index 7409bbac..f5a3e787 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/AppShell.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/AppShell.kt @@ -363,12 +363,12 @@ fun AppShell( onDismiss = { appState.showAddDialog = false }, onDownload = { url, fileName, speedLimit, priority, schedule, - resolvedUrl -> + resolvedUrl, selectedFileIds -> appState.showAddDialog = false appState.dismissError() appState.startDownload( url, fileName, speedLimit, priority, - schedule, resolvedUrl, + schedule, resolvedUrl, selectedFileIds, ) }, ) diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/dialog/AddDownloadDialog.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/dialog/AddDownloadDialog.kt index 06a002aa..c7f50e90 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/dialog/AddDownloadDialog.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/dialog/AddDownloadDialog.kt @@ -7,15 +7,19 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.animation.togetherWith +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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle @@ -24,6 +28,7 @@ import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.WarningAmber import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -38,8 +43,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -49,6 +56,7 @@ import com.linroid.ketch.api.DownloadPriority import com.linroid.ketch.api.DownloadSchedule import com.linroid.ketch.api.KetchError import com.linroid.ketch.api.ResolvedSource +import com.linroid.ketch.api.SourceFile import com.linroid.ketch.api.SpeedLimit import com.linroid.ketch.app.state.ResolveState import com.linroid.ketch.app.ui.common.PriorityIcon @@ -77,6 +85,7 @@ fun AddDownloadDialog( DownloadPriority, DownloadSchedule, ResolvedSource?, + selectedFileIds: Set, ) -> Unit, ) { var url by remember { mutableStateOf("") } @@ -100,6 +109,9 @@ fun AddDownloadDialog( var expanded by remember { mutableStateOf(DialogPanel.None) } + val selectedFileIds = remember { + mutableSetOf().toMutableStateList() + } val urlFocusRequester = remember { FocusRequester() } @@ -134,15 +146,22 @@ fun AddDownloadDialog( } } - // Pre-fill filename from resolved result + // Pre-fill filename and file selection from resolved result val resolved = (resolveState as? ResolveState.Resolved) ?.result LaunchedEffect(resolved) { - if (resolved != null && !fileNameEditedByUser) { - val suggested = resolved.suggestedFileName - if (!suggested.isNullOrBlank()) { - fileName = suggested + if (resolved != null) { + if (!fileNameEditedByUser) { + val suggested = resolved.suggestedFileName + if (!suggested.isNullOrBlank()) { + fileName = suggested + } } + // Select all files by default for multi-file sources + selectedFileIds.clear() + selectedFileIds.addAll( + resolved.files.map { it.id } + ) } } @@ -173,7 +192,9 @@ fun AddDownloadDialog( .focusRequester(urlFocusRequester), label = { Text("URL") }, singleLine = true, - placeholder = { Text("Enter URL...") }, + placeholder = { + Text("URL, magnet link, or .torrent") + }, isError = resolveState is ResolveState.Error, trailingIcon = { @@ -221,6 +242,38 @@ fun AddDownloadDialog( // Resolve result section ResolveInfoSection(resolveState) + // File selector for multi-file sources (e.g., torrent) + val hasFiles = resolved != null && + resolved.files.size > 1 + AnimatedVisibility( + visible = hasFiles, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + if (resolved != null && resolved.files.size > 1) { + FileSelector( + files = resolved.files, + selectedIds = selectedFileIds, + onToggle = { id -> + if (id in selectedFileIds) { + selectedFileIds.remove(id) + } else { + selectedFileIds.add(id) + } + }, + onSelectAll = { + selectedFileIds.clear() + selectedFileIds.addAll( + resolved.files.map { it.id } + ) + }, + onDeselectAll = { + selectedFileIds.clear() + }, + ) + } + } + // Credential fields shown on authentication failure AnimatedVisibility( visible = needsAuth, @@ -353,18 +406,26 @@ fun AddDownloadDialog( } }, confirmButton = { + val hasMultipleFiles = resolved != null && + resolved.files.size > 1 Button( onClick = { val downloadUrl = buildResolveUrl() if (downloadUrl.isNotEmpty()) { + val fileIds = if (hasMultipleFiles) { + selectedFileIds.toSet() + } else { + emptySet() + } onDownload( downloadUrl, fileName.trim(), selectedSpeed, selectedPriority, - selectedSchedule, resolved, + selectedSchedule, resolved, fileIds, ) } }, - enabled = url.isNotBlank(), + enabled = url.isNotBlank() && + (!hasMultipleFiles || selectedFileIds.isNotEmpty()), ) { Text("Download") } @@ -496,6 +557,29 @@ private fun ResolvedInfoCard( ) } + // File count for multi-file sources + if (resolved.files.size > 1) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Files", + style = + MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme + .onSurfaceVariant, + modifier = Modifier.width(80.dp), + ) + Text( + text = "${resolved.files.size} files", + style = + MaterialTheme.typography.labelSmall, + color = + MaterialTheme.colorScheme.onSurface, + ) + } + } + // Resume warning if (!resolved.supportsResume) { Spacer(Modifier.height(4.dp)) @@ -606,6 +690,110 @@ private fun CredentialFields( } } +@Composable +private fun FileSelector( + files: List, + selectedIds: List, + onToggle: (String) -> Unit, + onSelectAll: () -> Unit, + onDeselectAll: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme + .surfaceContainerHigh, + shape = RoundedCornerShape(8.dp), + ) { + Column( + modifier = Modifier.padding(8.dp), + ) { + // Header with select all / deselect all + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Files (${selectedIds.size}/${files.size})", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Row( + horizontalArrangement = + Arrangement.spacedBy(4.dp), + ) { + TextButton( + onClick = onSelectAll, + enabled = selectedIds.size < files.size, + ) { + Text( + text = "All", + style = + MaterialTheme.typography.labelSmall, + ) + } + TextButton( + onClick = onDeselectAll, + enabled = selectedIds.isNotEmpty(), + ) { + Text( + text = "None", + style = + MaterialTheme.typography.labelSmall, + ) + } + } + } + + // File list + LazyColumn( + modifier = Modifier.heightIn(max = 200.dp), + ) { + items(files, key = { it.id }) { file -> + val checked = file.id in selectedIds + Row( + modifier = Modifier.fillMaxWidth() + .clip(RoundedCornerShape(4.dp)) + .clickable { onToggle(file.id) } + .padding(vertical = 2.dp), + verticalAlignment = + Alignment.CenterVertically, + ) { + Checkbox( + checked = checked, + onCheckedChange = { onToggle(file.id) }, + modifier = Modifier.size(32.dp), + ) + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = file.name, + style = + MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme + .onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (file.size >= 0) { + Text( + text = formatBytes(file.size), + style = + MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme + .onSurfaceVariant, + ) + } + } + } + } + } + } + } +} + /** * Embeds [username] and [password] into a URL's authority section. * diff --git a/app/shared/src/iosMain/kotlin/com/linroid/ketch/app/instance/PlatformSources.ios.kt b/app/shared/src/iosMain/kotlin/com/linroid/ketch/app/instance/PlatformSources.ios.kt index 07aef20f..85853d09 100644 --- a/app/shared/src/iosMain/kotlin/com/linroid/ketch/app/instance/PlatformSources.ios.kt +++ b/app/shared/src/iosMain/kotlin/com/linroid/ketch/app/instance/PlatformSources.ios.kt @@ -2,6 +2,7 @@ package com.linroid.ketch.app.instance import com.linroid.ketch.core.engine.DownloadSource import com.linroid.ketch.ftp.FtpDownloadSource +import com.linroid.ketch.torrent.TorrentDownloadSource internal actual fun platformAdditionalSources(): List = - listOf(FtpDownloadSource()) + listOf(FtpDownloadSource(), TorrentDownloadSource()) diff --git a/app/shared/src/jvmMain/kotlin/com/linroid/ketch/app/instance/PlatformSources.jvm.kt b/app/shared/src/jvmMain/kotlin/com/linroid/ketch/app/instance/PlatformSources.jvm.kt index 07aef20f..85853d09 100644 --- a/app/shared/src/jvmMain/kotlin/com/linroid/ketch/app/instance/PlatformSources.jvm.kt +++ b/app/shared/src/jvmMain/kotlin/com/linroid/ketch/app/instance/PlatformSources.jvm.kt @@ -2,6 +2,7 @@ package com.linroid.ketch.app.instance import com.linroid.ketch.core.engine.DownloadSource import com.linroid.ketch.ftp.FtpDownloadSource +import com.linroid.ketch.torrent.TorrentDownloadSource internal actual fun platformAdditionalSources(): List = - listOf(FtpDownloadSource()) + listOf(FtpDownloadSource(), TorrentDownloadSource()) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fad4babb..1dfe1922 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,7 @@ kotlinx-io = "0.9.0" kotlinx-datetime = "0.7.1" kermit = "2.0.8" koog = "0.6.2" +libtorrent4j = "2.1.0-39" kotlinx-browser = "0.5.0" ktoml = "0.7.1" ktor = "3.4.0" @@ -86,6 +87,14 @@ ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json-jv ktor-serialization-json-common = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-serverTestHost = { module = "io.ktor:ktor-server-test-host-jvm", version.ref = "ktor" } ktoml-core = { module = "com.akuleshov7:ktoml-core", version.ref = "ktoml" } +libtorrent4j = { module = "org.libtorrent4j:libtorrent4j", version.ref = "libtorrent4j" } +libtorrent4j-android-arm64 = { module = "org.libtorrent4j:libtorrent4j-android-arm64", version.ref = "libtorrent4j" } +libtorrent4j-android-arm = { module = "org.libtorrent4j:libtorrent4j-android-arm", version.ref = "libtorrent4j" } +libtorrent4j-android-x86 = { module = "org.libtorrent4j:libtorrent4j-android-x86", version.ref = "libtorrent4j" } +libtorrent4j-android-x8664 = { module = "org.libtorrent4j:libtorrent4j-android-x86_64", version.ref = "libtorrent4j" } +libtorrent4j-macos = { module = "org.libtorrent4j:libtorrent4j-macos", version.ref = "libtorrent4j" } +libtorrent4j-linux = { module = "org.libtorrent4j:libtorrent4j-linux", version.ref = "libtorrent4j" } +libtorrent4j-windows = { module = "org.libtorrent4j:libtorrent4j-windows", version.ref = "libtorrent4j" } sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" } sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } diff --git a/library/core/src/commonMain/kotlin/com/linroid/ketch/core/engine/DownloadExecution.kt b/library/core/src/commonMain/kotlin/com/linroid/ketch/core/engine/DownloadExecution.kt index f28e13a4..ef593e94 100644 --- a/library/core/src/commonMain/kotlin/com/linroid/ketch/core/engine/DownloadExecution.kt +++ b/library/core/src/commonMain/kotlin/com/linroid/ketch/core/engine/DownloadExecution.kt @@ -14,6 +14,7 @@ import com.linroid.ketch.api.log.KetchLogger import com.linroid.ketch.core.KetchDispatchers import com.linroid.ketch.core.file.FileAccessor 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.file.resolveChildPath import com.linroid.ketch.core.task.TaskHandle @@ -173,7 +174,9 @@ internal class DownloadExecution( taskLimiter.delegate = createLimiter(request.speedLimit) val preResolved = if (resolved != null) resolvedUrl else null - runDownload(outputPath, total, preResolved) { ctx -> + runDownload( + outputPath, total, source.managesOwnFileIo, preResolved, + ) { ctx -> source.download(ctx) } } @@ -204,7 +207,9 @@ internal class DownloadExecution( totalBytes = taskRecord.totalBytes, ) - runDownload(outputPath, taskRecord.totalBytes) { ctx -> + runDownload( + outputPath, taskRecord.totalBytes, source.managesOwnFileIo, + ) { ctx -> context = ctx source.resume(ctx, resumeState) } @@ -214,14 +219,22 @@ internal class DownloadExecution( * Common download-to-completion sequence: creates a [FileAccessor], * builds the [DownloadContext], runs [downloadBlock] with retry, * flushes, persists completion, and cleans up. + * + * When [selfManagedIo] is `true`, the source handles its own file + * I/O so we use [NoOpFileAccessor] and skip flush/cleanup. */ private suspend fun runDownload( outputPath: String, total: Long, + selfManagedIo: Boolean = false, preResolved: ResolvedSource? = null, downloadBlock: suspend (DownloadContext) -> Unit, ) { - val fa = createFileAccessor(outputPath, dispatchers.io) + val fa = if (selfManagedIo) { + NoOpFileAccessor + } else { + createFileAccessor(outputPath, dispatchers.io) + } fileAccessor = fa var completed = false @@ -250,12 +263,14 @@ internal class DownloadExecution( } } - try { - fa.flush() - } catch (e: Exception) { - if (e is CancellationException) throw e - if (e is KetchError) throw e - throw KetchError.Disk(e) + if (!selfManagedIo) { + try { + fa.flush() + } catch (e: Exception) { + if (e is CancellationException) throw e + if (e is KetchError) throw e + throw KetchError.Disk(e) + } } handle.record.update { @@ -275,7 +290,9 @@ internal class DownloadExecution( log.i { "Download completed for taskId=$taskId" } handle.mutableState.value = DownloadState.Completed(outputPath) } finally { - cleanupAfterExecution(fa, completed) + if (!selfManagedIo) { + cleanupAfterExecution(fa, completed) + } } } diff --git a/library/core/src/commonMain/kotlin/com/linroid/ketch/core/engine/DownloadSource.kt b/library/core/src/commonMain/kotlin/com/linroid/ketch/core/engine/DownloadSource.kt index c0ec57ce..17d33eb3 100644 --- a/library/core/src/commonMain/kotlin/com/linroid/ketch/core/engine/DownloadSource.kt +++ b/library/core/src/commonMain/kotlin/com/linroid/ketch/core/engine/DownloadSource.kt @@ -17,6 +17,14 @@ interface DownloadSource { /** Unique identifier for this source type (e.g., "http", "torrent"). */ val type: String + /** + * Whether this source manages its own file I/O instead of using + * [DownloadContext.fileAccessor]. When `true`, the download engine + * skips [FileAccessor][com.linroid.ketch.core.file.FileAccessor] + * creation, flush, and cleanup. + */ + val managesOwnFileIo: Boolean get() = false + /** Returns true if this source can handle the given URL. */ fun canHandle(url: String): Boolean diff --git a/library/core/src/commonMain/kotlin/com/linroid/ketch/core/file/NoOpFileAccessor.kt b/library/core/src/commonMain/kotlin/com/linroid/ketch/core/file/NoOpFileAccessor.kt new file mode 100644 index 00000000..845d5f22 --- /dev/null +++ b/library/core/src/commonMain/kotlin/com/linroid/ketch/core/file/NoOpFileAccessor.kt @@ -0,0 +1,17 @@ +package com.linroid.ketch.core.file + +/** + * Stub [FileAccessor] that does nothing. + * + * Used when a [DownloadSource][com.linroid.ketch.core.engine.DownloadSource] + * manages its own file I/O (e.g., libtorrent). All operations are + * no-ops to satisfy the type system without touching the file system. + */ +internal object NoOpFileAccessor : FileAccessor { + override suspend fun writeAt(offset: Long, data: ByteArray) = Unit + override suspend fun flush() = Unit + override fun close() = Unit + override suspend fun delete() = Unit + override suspend fun size(): Long = 0L + override suspend fun preallocate(size: Long) = Unit +} diff --git a/library/torrent/build.gradle.kts b/library/torrent/build.gradle.kts new file mode 100644 index 00000000..92f0bc34 --- /dev/null +++ b/library/torrent/build.gradle.kts @@ -0,0 +1,74 @@ +@file:Suppress("UnstableApiUsage") + +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeSimulatorTest + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidKmpLibrary) + alias(libs.plugins.kotlinx.serialization) + alias(libs.plugins.mavenPublish) +} + +kotlin { + androidLibrary { + namespace = "com.linroid.ketch.torrent" + compileSdk = libs.versions.android.compileSdk.get().toInt() + minSdk = libs.versions.android.minSdk.get().toInt() + + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } + optimization { + consumerKeepRules.apply { + publish = true + file("consumer-rules.pro") + } + } + } + + iosArm64() + iosSimulatorArm64() + + jvm() + + // Intermediate source set shared between JVM and Android + applyDefaultHierarchyTemplate() + sourceSets { + val jvmAndAndroidMain by creating { + dependsOn(commonMain.get()) + } + androidMain.get().dependsOn(jvmAndAndroidMain) + jvmMain.get().dependsOn(jvmAndAndroidMain) + + commonMain.dependencies { + api(projects.library.core) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + } + named("jvmAndAndroidMain") { + dependencies { + implementation(libs.libtorrent4j) + } + } + jvmMain.dependencies { + runtimeOnly(libs.libtorrent4j.macos) + runtimeOnly(libs.libtorrent4j.linux) + runtimeOnly(libs.libtorrent4j.windows) + } + androidMain.dependencies { + runtimeOnly(libs.libtorrent4j.android.arm64) + runtimeOnly(libs.libtorrent4j.android.arm) + runtimeOnly(libs.libtorrent4j.android.x86) + runtimeOnly(libs.libtorrent4j.android.x8664) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + } +} + +tasks.withType().configureEach { + enabled = providers.gradleProperty("enableIosSimulatorTests").orNull == "true" +} diff --git a/library/torrent/consumer-rules.pro b/library/torrent/consumer-rules.pro new file mode 100644 index 00000000..2f23052b --- /dev/null +++ b/library/torrent/consumer-rules.pro @@ -0,0 +1,4 @@ +# libtorrent4j JNI classes — keep all native method bindings +-keep class org.libtorrent4j.swig.** { *; } +-keep class org.libtorrent4j.** { native ; } +-dontwarn org.libtorrent4j.** diff --git a/library/torrent/src/androidMain/kotlin/com/linroid/ketch/torrent/PlatformActuals.android.kt b/library/torrent/src/androidMain/kotlin/com/linroid/ketch/torrent/PlatformActuals.android.kt new file mode 100644 index 00000000..7a46fc7b --- /dev/null +++ b/library/torrent/src/androidMain/kotlin/com/linroid/ketch/torrent/PlatformActuals.android.kt @@ -0,0 +1,14 @@ +package com.linroid.ketch.torrent + +internal actual fun createTorrentEngine( + config: TorrentConfig, +): TorrentEngine = createLibtorrent4jEngine(config) + +internal actual fun encodeBase64(data: ByteArray): String = + jvmEncodeBase64(data) + +internal actual fun decodeBase64(data: String): ByteArray = + jvmDecodeBase64(data) + +internal actual fun sha1Digest(data: ByteArray): ByteArray = + jvmSha1Digest(data) diff --git a/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/Bencode.kt b/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/Bencode.kt new file mode 100644 index 00000000..a925b543 --- /dev/null +++ b/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/Bencode.kt @@ -0,0 +1,202 @@ +package com.linroid.ketch.torrent + +/** + * Bencode encoder/decoder for BitTorrent metadata. + * + * Supports the four bencode types: + * - **Integers**: `i42e` + * - **Byte strings**: `4:spam` + * - **Lists**: `l...e` + * - **Dictionaries**: `d...e` (keys are byte strings, sorted) + */ +internal object Bencode { + + /** + * Decodes a bencoded [ByteArray] into a Kotlin object. + * + * @return one of: [Long], [ByteArray], [List], or [Map] + * @throws IllegalArgumentException on malformed input + */ + fun decode(data: ByteArray): Any { + val result = decodeAt(data, 0) + return result.first + } + + /** + * Encodes a Kotlin object into a bencoded [ByteArray]. + * + * Accepts: [Long], [Int], [ByteArray], [String], [List], or + * [Map] (with [String] or [ByteArray] keys). + */ + fun encode(value: Any): ByteArray { + val buffer = mutableListOf() + encodeInto(value, buffer) + return buffer.toByteArray() + } + + // -- Decoding ---------------------------------------------------------- + + private fun decodeAt(data: ByteArray, offset: Int): Pair { + require(offset < data.size) { "Unexpected end of data at $offset" } + return when (data[offset].toInt().toChar()) { + 'i' -> decodeInt(data, offset) + 'l' -> decodeList(data, offset) + 'd' -> decodeDict(data, offset) + in '0'..'9' -> decodeString(data, offset) + else -> throw IllegalArgumentException( + "Unexpected byte '${data[offset].toInt().toChar()}' " + + "at offset $offset" + ) + } + } + + private fun decodeInt( + data: ByteArray, + offset: Int, + ): Pair { + var i = offset + 1 // skip 'i' + val end = data.indexOf('e'.code.toByte(), i) + require(end > i) { "Malformed integer at offset $offset" } + val str = data.decodeToString(i, end) + val value = str.toLongOrNull() + ?: throw IllegalArgumentException( + "Invalid integer '$str' at offset $offset" + ) + return value to (end + 1) + } + + private fun decodeString( + data: ByteArray, + offset: Int, + ): Pair { + val colon = data.indexOf(':'.code.toByte(), offset) + require(colon > offset) { + "Malformed string length at offset $offset" + } + val lenStr = data.decodeToString(offset, colon) + val len = lenStr.toIntOrNull() + ?: throw IllegalArgumentException( + "Invalid string length '$lenStr' at offset $offset" + ) + val start = colon + 1 + val end = start + len + require(end <= data.size) { + "String overflows data at offset $offset (len=$len)" + } + return data.copyOfRange(start, end) to end + } + + private fun decodeList( + data: ByteArray, + offset: Int, + ): Pair, Int> { + val list = mutableListOf() + var i = offset + 1 // skip 'l' + while (i < data.size && data[i] != 'e'.code.toByte()) { + val (value, next) = decodeAt(data, i) + list.add(value) + i = next + } + require(i < data.size) { "Unterminated list at offset $offset" } + return list to (i + 1) // skip 'e' + } + + private fun decodeDict( + data: ByteArray, + offset: Int, + ): Pair, Int> { + val map = linkedMapOf() + var i = offset + 1 // skip 'd' + while (i < data.size && data[i] != 'e'.code.toByte()) { + val (keyBytes, afterKey) = decodeString(data, i) + val key = keyBytes.decodeToString() + val (value, afterValue) = decodeAt(data, afterKey) + map[key] = value + i = afterValue + } + require(i < data.size) { + "Unterminated dictionary at offset $offset" + } + return map to (i + 1) // skip 'e' + } + + // -- Encoding ---------------------------------------------------------- + + private fun encodeInto(value: Any, buffer: MutableList) { + when (value) { + is Long -> encodeInt(value, buffer) + is Int -> encodeInt(value.toLong(), buffer) + is ByteArray -> encodeBytes(value, buffer) + is String -> encodeBytes(value.encodeToByteArray(), buffer) + is List<*> -> encodeList(value, buffer) + is Map<*, *> -> encodeDict(value, buffer) + else -> throw IllegalArgumentException( + "Cannot bencode ${value::class.simpleName}" + ) + } + } + + private fun encodeInt(value: Long, buffer: MutableList) { + buffer.add('i'.code.toByte()) + value.toString().encodeToByteArray().forEach { buffer.add(it) } + buffer.add('e'.code.toByte()) + } + + private fun encodeBytes( + value: ByteArray, + buffer: MutableList, + ) { + value.size.toString().encodeToByteArray() + .forEach { buffer.add(it) } + buffer.add(':'.code.toByte()) + value.forEach { buffer.add(it) } + } + + private fun encodeList( + value: List<*>, + buffer: MutableList, + ) { + buffer.add('l'.code.toByte()) + for (item in value) { + encodeInto(item ?: continue, buffer) + } + buffer.add('e'.code.toByte()) + } + + private fun encodeDict( + value: Map<*, *>, + buffer: MutableList, + ) { + buffer.add('d'.code.toByte()) + // Bencode dictionaries must have sorted keys + val sorted = value.entries.sortedBy { (k, _) -> + when (k) { + is String -> k + is ByteArray -> k.decodeToString() + else -> k.toString() + } + } + for ((k, v) in sorted) { + val keyBytes = when (k) { + is String -> k.encodeToByteArray() + is ByteArray -> k + else -> k.toString().encodeToByteArray() + } + encodeBytes(keyBytes, buffer) + encodeInto(v ?: continue, buffer) + } + buffer.add('e'.code.toByte()) + } + + // indexOf helper for ByteArray + private fun ByteArray.indexOf(byte: Byte, start: Int): Int { + for (i in start until size) { + if (this[i] == byte) return i + } + return -1 + } + + private fun ByteArray.decodeToString(start: Int, end: Int): String { + return copyOfRange(start, end).decodeToString() + } +} diff --git a/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/InfoHash.kt b/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/InfoHash.kt new file mode 100644 index 00000000..a2f8cace --- /dev/null +++ b/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/InfoHash.kt @@ -0,0 +1,45 @@ +package com.linroid.ketch.torrent + +import kotlinx.serialization.Serializable + +/** + * 20-byte SHA-1 info hash identifying a torrent. + * + * @property hex lowercase hex-encoded hash string (40 characters) + */ +@Serializable +@kotlin.jvm.JvmInline +internal value class InfoHash(val hex: String) { + init { + require(hex.length == 40 && hex.all { it in HEX_CHARS }) { + "InfoHash must be 40 hex characters, got: $hex" + } + } + + /** Returns the raw 20-byte hash. */ + fun toBytes(): ByteArray { + return ByteArray(20) { i -> + hex.substring(i * 2, i * 2 + 2).toInt(16).toByte() + } + } + + override fun toString(): String = hex + + companion object { + private const val HEX_CHARS = "0123456789abcdef" + + /** Creates an [InfoHash] from raw 20-byte SHA-1 data. */ + fun fromBytes(bytes: ByteArray): InfoHash { + require(bytes.size == 20) { + "SHA-1 hash must be 20 bytes, got ${bytes.size}" + } + val hex = bytes.joinToString("") { byte -> + (byte.toInt() and 0xFF).toString(16).padStart(2, '0') + } + return InfoHash(hex) + } + + /** Parses a 40-character hex string (case-insensitive). */ + fun fromHex(hex: String): InfoHash = InfoHash(hex.lowercase()) + } +} diff --git a/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/MagnetUri.kt b/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/MagnetUri.kt new file mode 100644 index 00000000..cee88136 --- /dev/null +++ b/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/MagnetUri.kt @@ -0,0 +1,162 @@ +package com.linroid.ketch.torrent + +/** + * Parsed magnet URI containing torrent identification and metadata. + * + * Format: `magnet:?xt=urn:btih:&dn=&tr=` + * + * @property infoHash 20-byte SHA-1 info hash + * @property displayName optional human-readable name (`dn` parameter) + * @property trackers list of tracker announce URLs (`tr` parameters) + */ +internal data class MagnetUri( + val infoHash: InfoHash, + val displayName: String? = null, + val trackers: List = emptyList(), +) { + + /** Reconstructs the magnet URI string. */ + fun toUri(): String = buildString { + append("magnet:?xt=urn:btih:") + append(infoHash.hex) + if (displayName != null) { + append("&dn=") + append(urlEncode(displayName)) + } + for (tracker in trackers) { + append("&tr=") + append(urlEncode(tracker)) + } + } + + companion object { + /** + * Parses a magnet URI string. + * + * @throws IllegalArgumentException if the URI is malformed or + * missing the `xt=urn:btih:` parameter + */ + fun parse(uri: String): MagnetUri { + require(uri.lowercase().startsWith("magnet:?")) { + "Not a magnet URI: $uri" + } + val query = uri.substringAfter('?') + val params = query.split('&') + + var infoHash: InfoHash? = null + var displayName: String? = null + val trackers = mutableListOf() + + for (param in params) { + val (key, value) = param.split('=', limit = 2) + .let { if (it.size == 2) it[0] to it[1] else continue } + + when (key.lowercase()) { + "xt" -> { + val lower = value.lowercase() + if (lower.startsWith("urn:btih:")) { + val hash = value.substring(9) + infoHash = parseInfoHash(hash) + } + } + "dn" -> displayName = urlDecode(value) + "tr" -> trackers.add(urlDecode(value)) + } + } + + requireNotNull(infoHash) { + "Magnet URI missing xt=urn:btih: parameter" + } + + return MagnetUri( + infoHash = infoHash, + displayName = displayName, + trackers = trackers, + ) + } + + private fun parseInfoHash(hash: String): InfoHash { + return when (hash.length) { + 40 -> InfoHash.fromHex(hash) + 32 -> InfoHash.fromBytes(base32Decode(hash)) + else -> throw IllegalArgumentException( + "Invalid info hash length: ${hash.length}" + ) + } + } + + /** + * Decodes a base32-encoded string (RFC 4648) to bytes. + * Used for magnet URIs with base32-encoded info hashes. + */ + internal fun base32Decode(input: String): ByteArray { + val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" + val upper = input.uppercase() + val output = mutableListOf() + var buffer = 0 + var bitsLeft = 0 + + for (ch in upper) { + if (ch == '=') break + val value = alphabet.indexOf(ch) + require(value >= 0) { "Invalid base32 character: $ch" } + buffer = (buffer shl 5) or value + bitsLeft += 5 + if (bitsLeft >= 8) { + bitsLeft -= 8 + output.add((buffer shr bitsLeft and 0xFF).toByte()) + } + } + return output.toByteArray() + } + + private fun urlDecode(value: String): String { + return buildString { + var i = 0 + while (i < value.length) { + when { + value[i] == '%' && i + 2 < value.length -> { + val hex = value.substring(i + 1, i + 3) + val byte = hex.toIntOrNull(16) + if (byte != null) { + append(byte.toChar()) + i += 3 + } else { + append(value[i]) + i++ + } + } + value[i] == '+' -> { + append(' ') + i++ + } + else -> { + append(value[i]) + i++ + } + } + } + } + } + + private fun urlEncode(value: String): String { + return buildString { + for (ch in value) { + when { + ch.isLetterOrDigit() || ch in "-._~" -> append(ch) + else -> { + val bytes = ch.toString().encodeToByteArray() + for (b in bytes) { + append('%') + append( + (b.toInt() and 0xFF).toString(16) + .uppercase().padStart(2, '0') + ) + } + } + } + } + } + } + } +} diff --git a/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/Sha1.kt b/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/Sha1.kt new file mode 100644 index 00000000..f441337f --- /dev/null +++ b/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/Sha1.kt @@ -0,0 +1,4 @@ +package com.linroid.ketch.torrent + +/** Returns the 20-byte SHA-1 digest of [data]. */ +internal expect fun sha1Digest(data: ByteArray): ByteArray diff --git a/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/TorrentConfig.kt b/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/TorrentConfig.kt new file mode 100644 index 00000000..f06b0bb4 --- /dev/null +++ b/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/TorrentConfig.kt @@ -0,0 +1,29 @@ +package com.linroid.ketch.torrent + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Configuration for the torrent engine. + * + * @property dhtEnabled whether to enable DHT for peer discovery + * @property maxActiveTorrents maximum number of active torrents + * @property metadataTimeoutSeconds timeout for magnet metadata + * resolution in seconds + * @property connectionsPerTorrent default connections per torrent + * @property enableUpload whether to seed after download completes + * @property listenPort port for incoming peer connections; 0 for + * random port + */ +data class TorrentConfig( + val dhtEnabled: Boolean = true, + val maxActiveTorrents: Int = 5, + val metadataTimeoutSeconds: Int = 120, + val connectionsPerTorrent: Int = 100, + val enableUpload: Boolean = false, + val listenPort: Int = 0, +) { + /** Metadata fetch timeout as a [Duration]. */ + val metadataTimeout: Duration + get() = metadataTimeoutSeconds.seconds +} diff --git a/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/TorrentDownloadSource.kt b/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/TorrentDownloadSource.kt new file mode 100644 index 00000000..75f2c6e5 --- /dev/null +++ b/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/TorrentDownloadSource.kt @@ -0,0 +1,417 @@ +package com.linroid.ketch.torrent + +import com.linroid.ketch.api.FileSelectionMode +import com.linroid.ketch.api.KetchError +import com.linroid.ketch.api.ResolvedSource +import com.linroid.ketch.api.Segment +import com.linroid.ketch.api.SourceFile +import com.linroid.ketch.api.log.KetchLogger +import com.linroid.ketch.core.engine.DownloadContext +import com.linroid.ketch.core.engine.DownloadSource +import com.linroid.ketch.core.engine.SourceResumeState +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.serialization.json.Json + +/** + * BitTorrent download source supporting `.torrent` files and + * `magnet:` URIs. + * + * Uses libtorrent4j as the underlying engine on JVM and Android. + * The engine manages its own file I/O, so [managesOwnFileIo] is + * `true` — Ketch skips FileAccessor operations for torrent + * downloads. + * + * Register with: + * ```kotlin + * Ketch(additionalSources = listOf(TorrentDownloadSource())) + * ``` + * + * @param config torrent engine configuration + */ +class TorrentDownloadSource( + private val config: TorrentConfig = TorrentConfig(), +) : DownloadSource { + + internal var engineFactory: () -> TorrentEngine = { + createTorrentEngine(config) + } + + private val log = KetchLogger("TorrentSource") + + override val type: String = TYPE + + override val managesOwnFileIo: Boolean = true + + private var engine: TorrentEngine? = null + + private suspend fun getEngine(): TorrentEngine { + val existing = engine + if (existing != null && existing.isRunning) return existing + val newEngine = engineFactory() + newEngine.start() + engine = newEngine + return newEngine + } + + override fun canHandle(url: String): Boolean { + val lower = url.lowercase() + return lower.startsWith("magnet:") || + lower.endsWith(".torrent") || + lower.contains(".torrent?") + } + + override suspend fun resolve( + url: String, + headers: Map, + ): ResolvedSource { + val metadata = try { + resolveMetadata(url) + } catch (e: Exception) { + if (e is CancellationException) throw e + if (e is KetchError) throw e + throw KetchError.SourceError(TYPE, e) + } + + val sourceFiles = metadata.files.map { file -> + SourceFile( + id = file.index.toString(), + name = file.path, + size = file.size, + metadata = mapOf("path" to file.path), + ) + } + + return ResolvedSource( + url = url, + sourceType = TYPE, + totalBytes = metadata.totalBytes, + supportsResume = true, + suggestedFileName = metadata.name, + maxSegments = metadata.files.size, + metadata = buildMap { + put(META_INFO_HASH, metadata.infoHash.hex) + put(META_NAME, metadata.name) + put(META_PIECE_LENGTH, metadata.pieceLength.toString()) + metadata.comment?.let { put(META_COMMENT, it) } + }, + files = sourceFiles, + selectionMode = FileSelectionMode.MULTIPLE, + ) + } + + private suspend fun resolveMetadata(url: String): TorrentMetadata { + return if (url.lowercase().startsWith("magnet:")) { + val engine = getEngine() + log.i { "Fetching metadata from magnet URI" } + engine.fetchMetadata(url) + ?: throw KetchError.Network( + Exception("Metadata fetch timed out for: $url") + ) + } else { + // .torrent URL — we expect pre-resolved metadata from + // torrent file bytes passed via DownloadRequest.resolvedSource + throw KetchError.SourceError( + TYPE, + Exception( + "Direct .torrent URL fetching not yet supported. " + + "Parse the .torrent file and pass metadata via " + + "DownloadRequest.resolvedSource" + ), + ) + } + } + + override suspend fun download(context: DownloadContext) { + val resolved = context.preResolved + ?: resolve(context.url, context.headers) + + val infoHash = resolved.metadata[META_INFO_HASH] + ?: throw KetchError.SourceError( + TYPE, Exception("Missing info hash in resolved metadata") + ) + + val selectedFileIds = context.request.selectedFileIds + val selectedIndices = if (selectedFileIds.isNotEmpty()) { + selectedFileIds.mapNotNull { it.toIntOrNull() }.toSet() + } else { + // Select all files by default + resolved.files.indices.toSet() + } + + val totalBytes = if (selectedFileIds.isNotEmpty()) { + resolved.files + .filter { it.id in selectedFileIds } + .sumOf { it.size } + } else { + resolved.totalBytes + } + + // Create one segment per selected file + val segments = createFileSegments(resolved, selectedIndices) + context.segments.value = segments + + val savePath = extractSavePath(context) + + log.i { + "Starting torrent download: infoHash=$infoHash, " + + "files=${selectedIndices.size}, totalBytes=$totalBytes" + } + + val engine = getEngine() + val magnetUri = if ( + context.url.lowercase().startsWith("magnet:") + ) { + context.url + } else { + null + } + + val session = try { + engine.addTorrent( + infoHash = infoHash, + savePath = savePath, + magnetUri = magnetUri, + selectedFileIndices = selectedIndices, + ) + } catch (e: Exception) { + if (e is CancellationException) throw e + if (e is KetchError) throw e + throw KetchError.SourceError(TYPE, e) + } + + // Apply speed limit if configured + val speedLimit = context.request.speedLimit + if (!speedLimit.isUnlimited) { + session.setDownloadRateLimit(speedLimit.bytesPerSecond) + } + + try { + monitorProgress(context, session, segments, totalBytes) + } catch (e: CancellationException) { + session.pause() + throw e + } catch (e: Exception) { + if (e is KetchError) throw e + throw KetchError.SourceError(TYPE, e) + } + } + + override suspend fun resume( + context: DownloadContext, + resumeState: SourceResumeState, + ) { + val state = try { + Json.decodeFromString(resumeState.data) + } catch (e: Exception) { + if (e is CancellationException) throw e + throw KetchError.CorruptResumeState(e.message, e) + } + + log.i { "Resuming torrent: infoHash=${state.infoHash}" } + + val resumeData = try { + decodeBase64(state.resumeData) + } catch (e: Exception) { + if (e is CancellationException) throw e + log.w(e) { "Failed to decode resume data, starting fresh" } + null + } + + val selectedIndices = state.selectedFileIds + .mapNotNull { it.toIntOrNull() } + .toSet() + + val engine = getEngine() + val magnetUri = if ( + context.url.lowercase().startsWith("magnet:") + ) { + context.url + } else { + null + } + + val session = try { + engine.addTorrent( + infoHash = state.infoHash, + savePath = state.savePath, + magnetUri = magnetUri, + selectedFileIndices = selectedIndices, + resumeData = resumeData, + ) + } catch (e: Exception) { + if (e is CancellationException) throw e + if (e is KetchError) throw e + throw KetchError.SourceError(TYPE, e) + } + + val speedLimit = context.request.speedLimit + if (!speedLimit.isUnlimited) { + session.setDownloadRateLimit(speedLimit.bytesPerSecond) + } + + val segments = context.segments.value + val totalBytes = state.totalBytes + + try { + monitorProgress(context, session, segments, totalBytes) + } catch (e: CancellationException) { + session.pause() + throw e + } catch (e: Exception) { + if (e is KetchError) throw e + throw KetchError.SourceError(TYPE, e) + } + } + + /** + * Monitors torrent download progress and maps it to Ketch + * segment progress until download completes or is canceled. + */ + private suspend fun monitorProgress( + context: DownloadContext, + session: TorrentSession, + segments: List, + totalBytes: Long, + ) { + // Wait for the download to complete + while (true) { + val sessionState = session.state.value + when (sessionState) { + TorrentSessionState.FINISHED, + TorrentSessionState.SEEDING -> break + + TorrentSessionState.STOPPED -> { + throw KetchError.SourceError( + TYPE, Exception("Torrent session stopped unexpectedly") + ) + } + else -> { + // Update progress + val downloaded = session.downloadedBytes.value + .coerceAtMost(totalBytes) + updateSegmentProgress(context, segments, downloaded, totalBytes) + context.onProgress(downloaded, totalBytes) + delay(PROGRESS_INTERVAL_MS) + } + } + } + + // Final progress update + updateSegmentProgress(context, segments, totalBytes, totalBytes) + context.onProgress(totalBytes, totalBytes) + + // Save resume data for potential re-seeding + val resumeData = session.saveResumeData() + if (resumeData != null) { + val savePath = extractSavePath(context) + val selectedIds = context.request.selectedFileIds.ifEmpty { + segments.map { it.index.toString() }.toSet() + } + val sourceState = TorrentResumeState( + infoHash = session.infoHash, + totalBytes = totalBytes, + resumeData = encodeBase64(resumeData), + selectedFileIds = selectedIds, + savePath = savePath, + ) + // Store resume state by updating the context's segments + // The DownloadExecution will persist this through TaskRecord + log.d { "Saved torrent resume data for ${session.infoHash}" } + } + } + + /** + * Distributes downloaded bytes across segments proportionally. + */ + private fun updateSegmentProgress( + context: DownloadContext, + segments: List, + downloaded: Long, + totalBytes: Long, + ) { + if (segments.isEmpty()) return + val fraction = if (totalBytes > 0) { + downloaded.toDouble() / totalBytes + } else { + 0.0 + } + val updated = segments.map { segment -> + val segDownloaded = (segment.totalBytes * fraction) + .toLong() + .coerceAtMost(segment.totalBytes) + segment.copy(downloadedBytes = segDownloaded) + } + context.segments.value = updated + } + + private fun createFileSegments( + resolved: ResolvedSource, + selectedIndices: Set, + ): List { + var offset = 0L + val segments = mutableListOf() + for (file in resolved.files) { + val fileIndex = file.id.toIntOrNull() ?: continue + if (fileIndex !in selectedIndices) continue + segments.add( + Segment( + index = segments.size, + start = offset, + end = offset + file.size - 1, + downloadedBytes = 0, + ) + ) + offset += file.size + } + return segments + } + + private fun extractSavePath(context: DownloadContext): String { + val dest = context.request.destination + return dest?.value ?: "downloads" + } + + companion object { + const val TYPE = "torrent" + internal const val META_INFO_HASH = "infoHash" + internal const val META_NAME = "name" + internal const val META_PIECE_LENGTH = "pieceLength" + internal const val META_COMMENT = "comment" + private const val PROGRESS_INTERVAL_MS = 500L + + fun buildResumeState( + infoHash: String, + totalBytes: Long, + resumeData: ByteArray, + selectedFileIds: Set, + savePath: String, + ): SourceResumeState { + val state = TorrentResumeState( + infoHash = infoHash, + totalBytes = totalBytes, + resumeData = encodeBase64(resumeData), + selectedFileIds = selectedFileIds, + savePath = savePath, + ) + return SourceResumeState( + sourceType = TYPE, + data = Json.encodeToString(state), + ) + } + } +} + +/** + * Platform-specific factory for [TorrentEngine]. + * Implemented in jvmAndAndroid source set. + */ +internal expect fun createTorrentEngine( + config: TorrentConfig, +): TorrentEngine + +/** Platform-specific base64 encoding. */ +internal expect fun encodeBase64(data: ByteArray): String + +/** Platform-specific base64 decoding. */ +internal expect fun decodeBase64(data: String): ByteArray diff --git a/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/TorrentEngine.kt b/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/TorrentEngine.kt new file mode 100644 index 00000000..1da70867 --- /dev/null +++ b/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/TorrentEngine.kt @@ -0,0 +1,74 @@ +package com.linroid.ketch.torrent + +/** + * Internal interface for the torrent engine backend. + * + * Abstracts the libtorrent4j session manager to allow future + * platform-specific implementations (e.g., iOS via cinterop). + * Only KMP-portable types are used in the interface. + */ +internal interface TorrentEngine { + /** Starts the engine. Must be called before any other operations. */ + suspend fun start() + + /** Stops the engine and releases all resources. */ + suspend fun stop() + + /** Whether the engine is currently running. */ + val isRunning: Boolean + + /** + * Fetches torrent metadata from a magnet URI. + * + * Blocks until metadata is available or the configured + * timeout is reached. + * + * @return parsed [TorrentMetadata], or null on timeout + */ + suspend fun fetchMetadata(magnetUri: String): TorrentMetadata? + + /** + * Adds a torrent for downloading. + * + * @param infoHash hex info hash + * @param savePath directory to save downloaded files + * @param magnetUri optional magnet URI (for magnet-based adds) + * @param torrentData optional raw .torrent file bytes + * @param selectedFileIndices indices of files to download + * @param resumeData optional resume data from a previous session + * @return a [TorrentSession] handle for this torrent + */ + suspend fun addTorrent( + infoHash: String, + savePath: String, + magnetUri: String? = null, + torrentData: ByteArray? = null, + selectedFileIndices: Set = emptySet(), + resumeData: ByteArray? = null, + ): TorrentSession + + /** + * Removes a torrent from the engine. + * + * @param infoHash hex info hash of the torrent to remove + * @param deleteFiles whether to also delete downloaded files + */ + suspend fun removeTorrent( + infoHash: String, + deleteFiles: Boolean = false, + ) + + /** + * Sets the global download rate limit. + * + * @param bytesPerSecond rate limit in bytes/sec, or 0 for unlimited + */ + fun setDownloadRateLimit(bytesPerSecond: Long) + + /** + * Sets the global upload rate limit. + * + * @param bytesPerSecond rate limit in bytes/sec, or 0 for unlimited + */ + fun setUploadRateLimit(bytesPerSecond: Long) +} diff --git a/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/TorrentMetadata.kt b/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/TorrentMetadata.kt new file mode 100644 index 00000000..4c7af78c --- /dev/null +++ b/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/TorrentMetadata.kt @@ -0,0 +1,144 @@ +package com.linroid.ketch.torrent + +/** + * Parsed torrent metadata extracted from a .torrent file or + * fetched via magnet link metadata exchange. + * + * @property infoHash the info hash identifying this torrent + * @property name torrent name from the info dictionary + * @property pieceLength size of each piece in bytes + * @property totalBytes total size across all files + * @property files list of files in this torrent + * @property trackers list of tracker announce URLs + * @property comment optional comment from the torrent creator + * @property createdBy optional tool that created the torrent + */ +internal data class TorrentMetadata( + val infoHash: InfoHash, + val name: String, + val pieceLength: Long, + val totalBytes: Long, + val files: List, + val trackers: List = emptyList(), + val comment: String? = null, + val createdBy: String? = null, +) { + /** + * A single file within a torrent. + * + * @property index zero-based file index + * @property path relative path within the torrent directory + * @property size file size in bytes + */ + data class TorrentFile( + val index: Int, + val path: String, + val size: Long, + ) + + companion object { + /** + * Parses a bencoded .torrent file into [TorrentMetadata]. + * + * @throws IllegalArgumentException if the torrent data is + * malformed or missing required fields + */ + @Suppress("UNCHECKED_CAST") + fun fromBencode(data: ByteArray): TorrentMetadata { + val root = Bencode.decode(data) as? Map + ?: throw IllegalArgumentException( + "Torrent root must be a dictionary" + ) + + val info = root["info"] as? Map + ?: throw IllegalArgumentException( + "Missing 'info' dictionary" + ) + + val name = (info["name"] as? ByteArray)?.decodeToString() + ?: throw IllegalArgumentException( + "Missing 'name' in info" + ) + + val pieceLength = info["piece length"] as? Long + ?: throw IllegalArgumentException( + "Missing 'piece length' in info" + ) + + // Calculate info hash from the raw bencoded info dict + val infoEncoded = Bencode.encode(info) + val infoHash = sha1Digest(infoEncoded) + + val files = parseFiles(info, name) + val totalBytes = files.sumOf { it.size } + + val trackers = parseTrackers(root) + + val comment = (root["comment"] as? ByteArray) + ?.decodeToString() + val createdBy = (root["created by"] as? ByteArray) + ?.decodeToString() + + return TorrentMetadata( + infoHash = InfoHash.fromBytes(infoHash), + name = name, + pieceLength = pieceLength, + totalBytes = totalBytes, + files = files, + trackers = trackers, + comment = comment, + createdBy = createdBy, + ) + } + + @Suppress("UNCHECKED_CAST") + private fun parseFiles( + info: Map, + name: String, + ): List { + val filesList = info["files"] as? List> + return if (filesList != null) { + // Multi-file torrent + filesList.mapIndexed { index, fileDict -> + val pathParts = (fileDict["path"] as? List) + ?.map { it.decodeToString() } + ?: throw IllegalArgumentException( + "Missing 'path' in file entry $index" + ) + val size = fileDict["length"] as? Long + ?: throw IllegalArgumentException( + "Missing 'length' in file entry $index" + ) + TorrentFile( + index = index, + path = (listOf(name) + pathParts).joinToString("/"), + size = size, + ) + } + } else { + // Single-file torrent + val length = info["length"] as? Long + ?: throw IllegalArgumentException( + "Missing 'length' in single-file torrent" + ) + listOf(TorrentFile(index = 0, path = name, size = length)) + } + } + + @Suppress("UNCHECKED_CAST") + private fun parseTrackers( + root: Map, + ): List { + val announceList = + root["announce-list"] as? List> + if (announceList != null) { + return announceList.flatMap { tier -> + tier.map { it.decodeToString() } + } + } + val announce = (root["announce"] as? ByteArray) + ?.decodeToString() + return if (announce != null) listOf(announce) else emptyList() + } + } +} diff --git a/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/TorrentResumeState.kt b/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/TorrentResumeState.kt new file mode 100644 index 00000000..08a9d5fc --- /dev/null +++ b/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/TorrentResumeState.kt @@ -0,0 +1,21 @@ +package com.linroid.ketch.torrent + +import kotlinx.serialization.Serializable + +/** + * Persisted resume state for torrent downloads. + * + * @property infoHash hex-encoded info hash + * @property totalBytes total selected bytes + * @property resumeData base64-encoded libtorrent resume data + * @property selectedFileIds set of selected file index strings + * @property savePath directory where files are saved + */ +@Serializable +internal data class TorrentResumeState( + val infoHash: String, + val totalBytes: Long, + val resumeData: String, + val selectedFileIds: Set, + val savePath: String, +) diff --git a/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/TorrentSession.kt b/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/TorrentSession.kt new file mode 100644 index 00000000..e9bab0b9 --- /dev/null +++ b/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/TorrentSession.kt @@ -0,0 +1,73 @@ +package com.linroid.ketch.torrent + +import kotlinx.coroutines.flow.StateFlow + +/** + * Handle for a single active torrent download. + * + * Provides control over file selection, speed limiting, + * pause/resume, and progress monitoring. + */ +internal interface TorrentSession { + /** Hex-encoded info hash. */ + val infoHash: String + + /** Observable download progress in bytes. */ + val downloadedBytes: StateFlow + + /** Observable torrent state. */ + val state: StateFlow + + /** Total bytes of selected files. */ + val totalBytes: Long + + /** Current download speed in bytes/sec. */ + val downloadSpeed: Long + + /** Pauses the torrent. */ + suspend fun pause() + + /** Resumes a paused torrent. */ + suspend fun resume() + + /** + * Sets file priorities. Index 0 = skip, 4 = normal, 7 = high. + * + * @param priorities map of file index to priority (0=skip, 4=normal) + */ + fun setFilePriorities(priorities: Map) + + /** Sets per-torrent download rate limit (bytes/sec, 0=unlimited). */ + fun setDownloadRateLimit(bytesPerSecond: Long) + + /** + * Saves resume data for later session recovery. + * + * @return raw resume data bytes, or null if unavailable + */ + suspend fun saveResumeData(): ByteArray? +} + +/** State of a torrent session. */ +internal enum class TorrentSessionState { + /** Waiting for metadata (magnet link). */ + CHECKING_METADATA, + + /** Checking existing files on disk. */ + CHECKING_FILES, + + /** Actively downloading. */ + DOWNLOADING, + + /** Download complete, may still be seeding. */ + FINISHED, + + /** Seeding (uploading). */ + SEEDING, + + /** Paused by user. */ + PAUSED, + + /** Stopped or errored. */ + STOPPED, +} diff --git a/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/BencodeEdgeCaseTest.kt b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/BencodeEdgeCaseTest.kt new file mode 100644 index 00000000..9c48b393 --- /dev/null +++ b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/BencodeEdgeCaseTest.kt @@ -0,0 +1,269 @@ +package com.linroid.ketch.torrent + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs + +/** + * Additional edge case tests for [Bencode] beyond the basics + * covered in [BencodeTest]. + */ +class BencodeEdgeCaseTest { + + // -- Integer edge cases -- + + @Test + fun decode_largePositiveInteger() { + val result = Bencode.decode( + "i9223372036854775807e".encodeToByteArray(), + ) + assertEquals(Long.MAX_VALUE, result) + } + + @Test + fun decode_largeNegativeInteger() { + val result = Bencode.decode( + "i-9223372036854775808e".encodeToByteArray(), + ) + assertEquals(Long.MIN_VALUE, result) + } + + @Test + fun decode_emptyInteger_throws() { + assertFailsWith { + Bencode.decode("ie".encodeToByteArray()) + } + } + + @Test + fun decode_nonNumericInteger_throws() { + assertFailsWith { + Bencode.decode("iabce".encodeToByteArray()) + } + } + + @Test + fun decode_integerMissingEndMarker_throws() { + assertFailsWith { + Bencode.decode("i42".encodeToByteArray()) + } + } + + // -- String edge cases -- + + @Test + fun decode_binaryDataInString() { + // String with bytes 0x00, 0xFF, 0x80 + val binary = byteArrayOf(0x00, 0xFF.toByte(), 0x80.toByte()) + val encoded = "3:".encodeToByteArray() + binary + val result = Bencode.decode(encoded) + assertIs(result) + assertContentEquals(binary, result) + } + + @Test + fun decode_longString() { + val str = "a".repeat(1000) + val encoded = "1000:$str".encodeToByteArray() + val result = Bencode.decode(encoded) + assertIs(result) + assertEquals(str, result.decodeToString()) + } + + @Test + fun decode_stringOverflowsData_throws() { + // Claims 10 bytes but only 3 available + assertFailsWith { + Bencode.decode("10:abc".encodeToByteArray()) + } + } + + @Test + fun decode_invalidStringLength_throws() { + assertFailsWith { + Bencode.decode("abc:data".encodeToByteArray()) + } + } + + // -- List edge cases -- + + @Test + fun decode_emptyList() { + val result = Bencode.decode("le".encodeToByteArray()) + assertIs>(result) + assertEquals(0, result.size) + } + + @Test + fun decode_nestedEmptyLists() { + val result = Bencode.decode("llleee".encodeToByteArray()) + assertIs>(result) + val inner = result[0] + assertIs>(inner) + val innermost = inner[0] + assertIs>(innermost) + assertEquals(0, innermost.size) + } + + @Test + fun decode_listWithMixedTypes() { + // list containing an integer, string, and another list + val data = "li42e3:fooli1eee".encodeToByteArray() + val result = Bencode.decode(data) + assertIs>(result) + assertEquals(3, result.size) + assertEquals(42L, result[0]) + assertEquals("foo", (result[1] as ByteArray).decodeToString()) + val innerList = result[2] + assertIs>(innerList) + assertEquals(listOf(1L), innerList) + } + + // -- Dictionary edge cases -- + + @Test + fun decode_emptyDictionary() { + val result = Bencode.decode("de".encodeToByteArray()) + assertIs>(result) + assertEquals(0, result.size) + } + + @Test + fun decode_unterminatedDictionary_throws() { + assertFailsWith { + Bencode.decode("d3:key5:value".encodeToByteArray()) + } + } + + @Test + fun decode_deeplyNestedStructure() { + // d -> d -> d -> d -> i42e + val data = "d1:ad1:bd1:cd1:di42eeeee".encodeToByteArray() + val result = Bencode.decode(data) + @Suppress("UNCHECKED_CAST") + val a = (result as Map)["a"] as Map + @Suppress("UNCHECKED_CAST") + val b = a["b"] as Map + @Suppress("UNCHECKED_CAST") + val c = b["c"] as Map + assertEquals(42L, c["d"]) + } + + // -- Encoding edge cases -- + + @Test + fun encode_intValue() { + // Verify Int (not Long) encoding works + val encoded = Bencode.encode(42) + assertEquals("i42e", encoded.decodeToString()) + } + + @Test + fun encode_negativeInteger() { + val encoded = Bencode.encode(-1L) + assertEquals("i-1e", encoded.decodeToString()) + } + + @Test + fun encode_zeroLengthByteArray() { + val encoded = Bencode.encode(ByteArray(0)) + assertEquals("0:", encoded.decodeToString()) + } + + @Test + fun encode_emptyStringValue() { + val encoded = Bencode.encode("") + assertEquals("0:", encoded.decodeToString()) + } + + @Test + fun encode_emptyList() { + val encoded = Bencode.encode(emptyList()) + assertEquals("le", encoded.decodeToString()) + } + + @Test + fun encode_emptyDictionary() { + val encoded = Bencode.encode(emptyMap()) + assertEquals("de", encoded.decodeToString()) + } + + @Test + fun encode_dictionaryKeysSorted() { + val encoded = Bencode.encode( + mapOf("z" to 1L, "a" to 2L, "m" to 3L), + ) + // Keys must be sorted: a=2, m=3, z=1 + assertEquals("d1:ai2e1:mi3e1:zi1ee", encoded.decodeToString()) + } + + @Test + fun encode_listWithNulls_skipsNulls() { + val list = listOf("a", null, "b") + val encoded = Bencode.encode(list) + assertEquals("l1:a1:be", encoded.decodeToString()) + } + + @Test + fun encode_unsupportedType_throws() { + assertFailsWith { + Bencode.encode(3.14) + } + } + + @Test + fun encode_byteArrayKey_inDictionary() { + val dict = mapOf( + "key".encodeToByteArray() to 1L, + ) + val encoded = Bencode.encode(dict) + assertEquals("d3:keyi1ee", encoded.decodeToString()) + } + + // -- Round-trip edge cases -- + + @Test + fun roundtrip_emptyString() { + val encoded = Bencode.encode("") + val decoded = Bencode.decode(encoded) + assertIs(decoded) + assertEquals("", decoded.decodeToString()) + } + + @Test + fun roundtrip_nestedDictWithList() { + val original = mapOf( + "data" to listOf(1L, 2L, 3L), + "meta" to mapOf("key" to "val"), + ) + val encoded = Bencode.encode(original) + @Suppress("UNCHECKED_CAST") + val decoded = Bencode.decode(encoded) as Map + assertEquals(listOf(1L, 2L, 3L), decoded["data"]) + @Suppress("UNCHECKED_CAST") + val meta = decoded["meta"] as Map + assertEquals( + "val", + (meta["key"] as ByteArray).decodeToString(), + ) + } + + // -- Empty/malformed data -- + + @Test + fun decode_emptyByteArray_throws() { + assertFailsWith { + Bencode.decode(ByteArray(0)) + } + } + + @Test + fun decode_unexpectedByte_throws() { + assertFailsWith { + // '!' is not a valid bencode start character + Bencode.decode("!".encodeToByteArray()) + } + } +} diff --git a/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/BencodeTest.kt b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/BencodeTest.kt new file mode 100644 index 00000000..85fd0c28 --- /dev/null +++ b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/BencodeTest.kt @@ -0,0 +1,143 @@ +package com.linroid.ketch.torrent + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs + +class BencodeTest { + + @Test + fun decode_integer() { + val result = Bencode.decode("i42e".encodeToByteArray()) + assertEquals(42L, result) + } + + @Test + fun decode_negativeInteger() { + val result = Bencode.decode("i-7e".encodeToByteArray()) + assertEquals(-7L, result) + } + + @Test + fun decode_zero() { + val result = Bencode.decode("i0e".encodeToByteArray()) + assertEquals(0L, result) + } + + @Test + fun decode_string() { + val result = Bencode.decode("4:spam".encodeToByteArray()) + assertIs(result) + assertEquals("spam", result.decodeToString()) + } + + @Test + fun decode_emptyString() { + val result = Bencode.decode("0:".encodeToByteArray()) + assertIs(result) + assertEquals("", result.decodeToString()) + } + + @Test + fun decode_list() { + val result = Bencode.decode("l4:spam4:eggse".encodeToByteArray()) + assertIs>(result) + assertEquals(2, result.size) + assertEquals("spam", (result[0] as ByteArray).decodeToString()) + assertEquals("eggs", (result[1] as ByteArray).decodeToString()) + } + + @Test + fun decode_dictionary() { + val data = "d3:cow3:moo4:spam4:eggse".encodeToByteArray() + val result = Bencode.decode(data) + assertIs>(result) + assertEquals( + "moo", + (result["cow"] as ByteArray).decodeToString(), + ) + assertEquals( + "eggs", + (result["spam"] as ByteArray).decodeToString(), + ) + } + + @Test + fun decode_nestedStructure() { + val data = "d4:listli1ei2ei3ee5:valuei42ee".encodeToByteArray() + val result = Bencode.decode(data) + assertIs>(result) + val list = result["list"] + assertIs>(list) + assertEquals(listOf(1L, 2L, 3L), list) + assertEquals(42L, result["value"]) + } + + @Test + fun encode_integer() { + val encoded = Bencode.encode(42L) + assertEquals("i42e", encoded.decodeToString()) + } + + @Test + fun encode_string() { + val encoded = Bencode.encode("spam") + assertEquals("4:spam", encoded.decodeToString()) + } + + @Test + fun encode_list() { + val encoded = Bencode.encode(listOf("spam", "eggs")) + assertEquals("l4:spam4:eggse", encoded.decodeToString()) + } + + @Test + fun encode_dictionary() { + val encoded = Bencode.encode( + mapOf("cow" to "moo", "spam" to "eggs"), + ) + // Keys must be sorted + assertEquals( + "d3:cow3:moo4:spam4:eggse", + encoded.decodeToString(), + ) + } + + @Test + fun roundtrip_complexStructure() { + val original = mapOf( + "info" to mapOf( + "name" to "test.txt", + "piece length" to 262144L, + "length" to 1024L, + ), + "announce" to "http://tracker.example.com/announce", + ) + val encoded = Bencode.encode(original) + @Suppress("UNCHECKED_CAST") + val decoded = Bencode.decode(encoded) as Map + @Suppress("UNCHECKED_CAST") + val info = decoded["info"] as Map + assertEquals( + "test.txt", + (info["name"] as ByteArray).decodeToString(), + ) + assertEquals(262144L, info["piece length"]) + assertEquals(1024L, info["length"]) + } + + @Test + fun decode_malformedInput_throws() { + assertFailsWith { + Bencode.decode("x".encodeToByteArray()) + } + } + + @Test + fun decode_unterminatedList_throws() { + assertFailsWith { + Bencode.decode("li42e".encodeToByteArray()) + } + } +} diff --git a/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/FakeTorrentEngine.kt b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/FakeTorrentEngine.kt new file mode 100644 index 00000000..bf6c4fb5 --- /dev/null +++ b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/FakeTorrentEngine.kt @@ -0,0 +1,138 @@ +package com.linroid.ketch.torrent + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Fake [TorrentEngine] for testing [TorrentDownloadSource] without + * a real libtorrent backend. + */ +internal class FakeTorrentEngine : TorrentEngine { + + var started = false + private set + var stopped = false + private set + + override var isRunning: Boolean = false + private set + + var fetchMetadataResult: TorrentMetadata? = null + var fetchMetadataError: Exception? = null + var addTorrentResult: FakeTorrentSession? = null + var addTorrentError: Exception? = null + var removedTorrents = mutableListOf>() + var downloadRateLimit = 0L + private set + var uploadRateLimit = 0L + private set + + override suspend fun start() { + started = true + isRunning = true + } + + override suspend fun stop() { + stopped = true + isRunning = false + } + + override suspend fun fetchMetadata( + magnetUri: String, + ): TorrentMetadata? { + fetchMetadataError?.let { throw it } + return fetchMetadataResult + } + + override suspend fun addTorrent( + infoHash: String, + savePath: String, + magnetUri: String?, + torrentData: ByteArray?, + selectedFileIndices: Set, + resumeData: ByteArray?, + ): TorrentSession { + addTorrentError?.let { throw it } + return addTorrentResult + ?: throw IllegalStateException("addTorrentResult not set") + } + + override suspend fun removeTorrent( + infoHash: String, + deleteFiles: Boolean, + ) { + removedTorrents.add(infoHash to deleteFiles) + } + + override fun setDownloadRateLimit(bytesPerSecond: Long) { + downloadRateLimit = bytesPerSecond + } + + override fun setUploadRateLimit(bytesPerSecond: Long) { + uploadRateLimit = bytesPerSecond + } +} + +/** + * Fake [TorrentSession] for testing download/resume flows. + */ +internal class FakeTorrentSession( + override val infoHash: String, + override val totalBytes: Long = 0, +) : TorrentSession { + + private val _downloadedBytes = MutableStateFlow(0L) + override val downloadedBytes: StateFlow = _downloadedBytes + + private val _state = + MutableStateFlow(TorrentSessionState.DOWNLOADING) + override val state: StateFlow = _state + + override var downloadSpeed: Long = 0L + + var paused = false + private set + var resumed = false + private set + var filePriorities = emptyMap() + private set + var sessionDownloadRateLimit = 0L + private set + var savedResumeData: ByteArray? = null + + override suspend fun pause() { + paused = true + _state.value = TorrentSessionState.PAUSED + } + + override suspend fun resume() { + resumed = true + _state.value = TorrentSessionState.DOWNLOADING + } + + override fun setFilePriorities(priorities: Map) { + filePriorities = priorities + } + + override fun setDownloadRateLimit(bytesPerSecond: Long) { + sessionDownloadRateLimit = bytesPerSecond + } + + override suspend fun saveResumeData(): ByteArray? { + return savedResumeData + } + + // Test helpers + + fun setDownloaded(bytes: Long) { + _downloadedBytes.value = bytes + } + + fun finish() { + _state.value = TorrentSessionState.FINISHED + } + + fun stopUnexpectedly() { + _state.value = TorrentSessionState.STOPPED + } +} diff --git a/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/InfoHashEdgeCaseTest.kt b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/InfoHashEdgeCaseTest.kt new file mode 100644 index 00000000..3fecb9ec --- /dev/null +++ b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/InfoHashEdgeCaseTest.kt @@ -0,0 +1,155 @@ +package com.linroid.ketch.torrent + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +/** + * Additional edge case tests for [InfoHash] beyond the basics + * covered in [InfoHashTest]. + */ +class InfoHashEdgeCaseTest { + + @Test + fun fromHex_lowercasesUppercase() { + val hash = InfoHash.fromHex( + "AABBCCDDEE11223344556677889900AABBCCDDEE", + ) + assertEquals( + "aabbccddee11223344556677889900aabbccddee", + hash.hex, + ) + } + + @Test + fun fromHex_mixedCase() { + val hash = InfoHash.fromHex( + "aAbBcCdDeE11223344556677889900AaBbCcDdEe", + ) + assertEquals( + "aabbccddee11223344556677889900aabbccddee", + hash.hex, + ) + } + + @Test + fun constructor_rejectsUppercase() { + // Constructor requires lowercase; fromHex normalizes + assertFailsWith { + InfoHash("AABBCCDDEE11223344556677889900AABBCCDDEE") + } + } + + @Test + fun constructor_rejects39Characters() { + assertFailsWith { + InfoHash("aabbccddee11223344556677889900aabbccdde") + } + } + + @Test + fun constructor_rejects41Characters() { + assertFailsWith { + InfoHash("aabbccddee11223344556677889900aabbccddeef") + } + } + + @Test + fun constructor_rejectsEmptyString() { + assertFailsWith { + InfoHash("") + } + } + + @Test + fun constructor_rejectsNonHexAtEnd() { + // 39 valid hex + 1 invalid character 'g' + assertFailsWith { + InfoHash("0123456789abcdef0123456789abcdef0123456g") + } + } + + @Test + fun fromBytes_allZeros() { + val bytes = ByteArray(20) + val hash = InfoHash.fromBytes(bytes) + assertEquals( + "0000000000000000000000000000000000000000", + hash.hex, + ) + } + + @Test + fun fromBytes_allOnes() { + val bytes = ByteArray(20) { 0xFF.toByte() } + val hash = InfoHash.fromBytes(bytes) + assertEquals( + "ffffffffffffffffffffffffffffffffffffffff", + hash.hex, + ) + } + + @Test + fun fromBytes_rejects19Bytes() { + assertFailsWith { + InfoHash.fromBytes(ByteArray(19)) + } + } + + @Test + fun fromBytes_rejects21Bytes() { + assertFailsWith { + InfoHash.fromBytes(ByteArray(21)) + } + } + + @Test + fun fromBytes_rejectsEmptyArray() { + assertFailsWith { + InfoHash.fromBytes(ByteArray(0)) + } + } + + @Test + fun toBytes_allZerosHash() { + val hash = InfoHash( + "0000000000000000000000000000000000000000", + ) + val bytes = hash.toBytes() + assertEquals(20, bytes.size) + for (b in bytes) { + assertEquals(0, b.toInt()) + } + } + + @Test + fun toBytes_preservesHighBits() { + // 0xff = -1 as signed byte + val hash = InfoHash( + "ffffffffffffffffffffffffffffffffffffffff", + ) + val bytes = hash.toBytes() + for (b in bytes) { + assertEquals(0xFF.toByte(), b) + } + } + + @Test + fun equality_sameHex_areEqual() { + val a = InfoHash.fromHex( + "0123456789abcdef0123456789abcdef01234567", + ) + val b = InfoHash.fromHex( + "0123456789ABCDEF0123456789ABCDEF01234567", + ) + assertEquals(a, b) + } + + @Test + fun equality_fromBytesAndFromHex_areEqual() { + val bytes = ByteArray(20) { (it * 13).toByte() } + val fromBytes = InfoHash.fromBytes(bytes) + val fromHex = InfoHash.fromHex(fromBytes.hex) + assertEquals(fromBytes, fromHex) + } +} diff --git a/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/InfoHashTest.kt b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/InfoHashTest.kt new file mode 100644 index 00000000..7bbe3628 --- /dev/null +++ b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/InfoHashTest.kt @@ -0,0 +1,59 @@ +package com.linroid.ketch.torrent + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class InfoHashTest { + + @Test + fun fromHex_validHash() { + val hash = InfoHash.fromHex( + "AABBCCDDEE11223344556677889900AABBCCDDEE", + ) + assertEquals( + "aabbccddee11223344556677889900aabbccddee", + hash.hex, + ) + } + + @Test + fun fromBytes_roundtrip() { + val bytes = ByteArray(20) { it.toByte() } + val hash = InfoHash.fromBytes(bytes) + val roundtripped = hash.toBytes() + assertEquals(bytes.toList(), roundtripped.toList()) + } + + @Test + fun fromBytes_wrongSize_throws() { + assertFailsWith { + InfoHash.fromBytes(ByteArray(10)) + } + } + + @Test + fun constructor_wrongLength_throws() { + assertFailsWith { + InfoHash("abc") + } + } + + @Test + fun constructor_invalidChars_throws() { + assertFailsWith { + InfoHash("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz") + } + } + + @Test + fun toString_returnsHex() { + val hash = InfoHash.fromHex( + "0123456789abcdef0123456789abcdef01234567", + ) + assertEquals( + "0123456789abcdef0123456789abcdef01234567", + hash.toString(), + ) + } +} diff --git a/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/MagnetUriEdgeCaseTest.kt b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/MagnetUriEdgeCaseTest.kt new file mode 100644 index 00000000..2cc03e93 --- /dev/null +++ b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/MagnetUriEdgeCaseTest.kt @@ -0,0 +1,232 @@ +package com.linroid.ketch.torrent + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Additional edge case tests for [MagnetUri] beyond the basics + * covered in [MagnetUriTest]. + */ +class MagnetUriEdgeCaseTest { + + private val validHash = + "0123456789abcdef0123456789abcdef01234567" + + // -- Multiple trackers -- + + @Test + fun parse_multipleTrackers_preservesOrder() { + val uri = "magnet:?xt=urn:btih:$validHash" + + "&tr=http%3A%2F%2Ftracker1.com" + + "&tr=http%3A%2F%2Ftracker2.com" + + "&tr=http%3A%2F%2Ftracker3.com" + val parsed = MagnetUri.parse(uri) + assertEquals(3, parsed.trackers.size) + assertEquals("http://tracker1.com", parsed.trackers[0]) + assertEquals("http://tracker2.com", parsed.trackers[1]) + assertEquals("http://tracker3.com", parsed.trackers[2]) + } + + @Test + fun parse_noTrackers() { + val uri = "magnet:?xt=urn:btih:$validHash&dn=NoTrackers" + val parsed = MagnetUri.parse(uri) + assertEquals(0, parsed.trackers.size) + assertEquals("NoTrackers", parsed.displayName) + } + + // -- Display name encoding -- + + @Test + fun parse_displayNameWithSpaces_urlDecoded() { + val uri = "magnet:?xt=urn:btih:$validHash&dn=My+Cool+File" + val parsed = MagnetUri.parse(uri) + assertEquals("My Cool File", parsed.displayName) + } + + @Test + fun parse_displayNameWithPercentEncoding() { + val uri = "magnet:?xt=urn:btih:$validHash&dn=File%20%26%20Data" + val parsed = MagnetUri.parse(uri) + assertEquals("File & Data", parsed.displayName) + } + + @Test + fun parse_noDisplayName() { + val uri = "magnet:?xt=urn:btih:$validHash" + val parsed = MagnetUri.parse(uri) + assertNull(parsed.displayName) + } + + // -- Missing/invalid required fields -- + + @Test + fun parse_missingXtParameter_throws() { + assertFailsWith { + MagnetUri.parse("magnet:?dn=test&tr=http://tracker.com") + } + } + + @Test + fun parse_nonBtihXtParameter_throws() { + // Has xt but not btih (e.g., ed2k) + assertFailsWith { + MagnetUri.parse("magnet:?xt=urn:ed2k:abc123") + } + } + + @Test + fun parse_emptyQueryString_throws() { + assertFailsWith { + MagnetUri.parse("magnet:?") + } + } + + @Test + fun parse_invalidInfoHashLength_throws() { + // 20 chars is neither 40 (hex) nor 32 (base32) + assertFailsWith { + MagnetUri.parse("magnet:?xt=urn:btih:01234567890123456789") + } + } + + // -- Unknown parameters are ignored -- + + @Test + fun parse_unknownParameters_ignored() { + val uri = "magnet:?xt=urn:btih:$validHash" + + "&xl=1024&as=http%3A%2F%2Falt.com&xs=foo" + val parsed = MagnetUri.parse(uri) + assertEquals(validHash, parsed.infoHash.hex) + assertNull(parsed.displayName) + assertEquals(0, parsed.trackers.size) + } + + // -- Parameter without value -- + + @Test + fun parse_parameterWithoutValue_skipped() { + val uri = "magnet:?xt=urn:btih:$validHash&broken&dn=test" + val parsed = MagnetUri.parse(uri) + assertEquals(validHash, parsed.infoHash.hex) + assertEquals("test", parsed.displayName) + } + + // -- toUri round-trip -- + + @Test + fun toUri_noDisplayNameNoTrackers() { + val magnet = MagnetUri( + infoHash = InfoHash.fromHex(validHash), + ) + val uri = magnet.toUri() + assertTrue(uri.startsWith("magnet:?xt=urn:btih:")) + assertTrue(uri.contains(validHash)) + // No &dn= or &tr= in the URI + assertTrue("&dn=" !in uri) + assertTrue("&tr=" !in uri) + } + + @Test + fun toUri_specialCharsInDisplayName_encoded() { + val magnet = MagnetUri( + infoHash = InfoHash.fromHex(validHash), + displayName = "File [2024] (HD).mkv", + ) + val uri = magnet.toUri() + // Round-trip should preserve the display name + val parsed = MagnetUri.parse(uri) + assertEquals("File [2024] (HD).mkv", parsed.displayName) + } + + @Test + fun toUri_multipleTrackers_allPresent() { + val trackers = listOf( + "http://tracker1.com/announce", + "udp://tracker2.com:6881", + "http://tracker3.com/announce?passkey=abc", + ) + val magnet = MagnetUri( + infoHash = InfoHash.fromHex(validHash), + trackers = trackers, + ) + val uri = magnet.toUri() + val parsed = MagnetUri.parse(uri) + assertEquals(3, parsed.trackers.size) + assertEquals(trackers, parsed.trackers) + } + + // -- Base32 edge cases -- + + @Test + fun base32Decode_withPadding() { + // "MFRA====" pads to full block; should still decode "ab" + val decoded = MagnetUri.base32Decode("MFRA====") + assertEquals( + listOf('a'.code.toByte(), 'b'.code.toByte()), + decoded.toList(), + ) + } + + @Test + fun base32Decode_invalidCharacter_throws() { + assertFailsWith { + MagnetUri.base32Decode("MFRA1!!!") // '1' is valid, '!' is not + } + } + + @Test + fun base32Decode_lowercase_works() { + // Should be case-insensitive + val decoded = MagnetUri.base32Decode("mfra") + assertEquals( + listOf('a'.code.toByte(), 'b'.code.toByte()), + decoded.toList(), + ) + } + + @Test + fun base32Decode_emptyString() { + val decoded = MagnetUri.base32Decode("") + assertEquals(0, decoded.size) + } + + // -- URL encoding/decoding edge cases -- + + @Test + fun parse_percentEncodedTrackerUrls() { + val tracker = + "http://tracker.example.com:2710/announce?passkey=abc123" + val encoded = tracker + .replace(":", "%3A") + .replace("/", "%2F") + .replace("?", "%3F") + .replace("=", "%3D") + val uri = "magnet:?xt=urn:btih:$validHash&tr=$encoded" + val parsed = MagnetUri.parse(uri) + assertEquals(tracker, parsed.trackers[0]) + } + + @Test + fun parse_invalidPercentEncoding_preservesLiteral() { + // %ZZ is not valid hex, should be kept as literal + val uri = "magnet:?xt=urn:btih:$validHash&dn=test%ZZdata" + val parsed = MagnetUri.parse(uri) + // The '%' is kept as literal, then 'Z', 'Z', 'data' + val dn = assertNotNull(parsed.displayName) + assertTrue(dn.contains("%")) + } + + @Test + fun parse_truncatedPercentEncoding_preservesLiteral() { + // %2 at end of string (missing second hex digit) + val uri = "magnet:?xt=urn:btih:$validHash&dn=test%2" + val parsed = MagnetUri.parse(uri) + val dn = assertNotNull(parsed.displayName) + assertTrue(dn.endsWith("%2")) + } +} diff --git a/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/MagnetUriTest.kt b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/MagnetUriTest.kt new file mode 100644 index 00000000..b9f8c589 --- /dev/null +++ b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/MagnetUriTest.kt @@ -0,0 +1,93 @@ +package com.linroid.ketch.torrent + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class MagnetUriTest { + + private val sampleHash = + "aabbccddee11223344556677889900aabbccddee" + + @Test + fun parse_minimalMagnet() { + val uri = "magnet:?xt=urn:btih:$sampleHash" + val parsed = MagnetUri.parse(uri) + assertEquals(sampleHash, parsed.infoHash.hex) + assertNull(parsed.displayName) + assertEquals(emptyList(), parsed.trackers) + } + + @Test + fun parse_withDisplayNameAndTrackers() { + val uri = "magnet:?xt=urn:btih:$sampleHash" + + "&dn=My+File" + + "&tr=http%3A%2F%2Ftracker.example.com%2Fannounce" + + "&tr=udp%3A%2F%2Ftracker2.example.com%3A6881" + val parsed = MagnetUri.parse(uri) + assertEquals(sampleHash, parsed.infoHash.hex) + assertEquals("My File", parsed.displayName) + assertEquals(2, parsed.trackers.size) + assertEquals( + "http://tracker.example.com/announce", + parsed.trackers[0], + ) + } + + @Test + fun parse_base32InfoHash() { + // 32-char base32 encodes 20 bytes + val base32 = "VK3GMZB5GZMH2LYMIWZMZB6MJMYFEY3F" + val uri = "magnet:?xt=urn:btih:$base32" + val parsed = MagnetUri.parse(uri) + assertEquals(40, parsed.infoHash.hex.length) + } + + @Test + fun parse_missingInfoHash_throws() { + assertFailsWith { + MagnetUri.parse("magnet:?dn=test") + } + } + + @Test + fun parse_notMagnet_throws() { + assertFailsWith { + MagnetUri.parse("http://example.com") + } + } + + @Test + fun toUri_roundtrip() { + val original = MagnetUri( + infoHash = InfoHash.fromHex(sampleHash), + displayName = "Test File", + trackers = listOf("http://tracker.example.com/announce"), + ) + val uri = original.toUri() + val parsed = MagnetUri.parse(uri) + assertEquals(original.infoHash, parsed.infoHash) + assertEquals(original.displayName, parsed.displayName) + assertEquals(original.trackers.size, parsed.trackers.size) + } + + @Test + fun parse_caseInsensitive() { + val uri = "MAGNET:?XT=URN:BTIH:$sampleHash" + val parsed = MagnetUri.parse(uri) + assertEquals(sampleHash, parsed.infoHash.hex) + } + + @Test + fun base32Decode_validInput() { + // M=12,F=5,R=17,A=0 -> 01100 00101 10001 00000 + // -> 01100001 01100010 -> 0x61 0x62 = "ab" + val decoded = MagnetUri.base32Decode("MFRA") + assertEquals( + listOf('a'.code.toByte(), 'b'.code.toByte()), + decoded.toList(), + ) + } +} diff --git a/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/TorrentConfigEdgeCaseTest.kt b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/TorrentConfigEdgeCaseTest.kt new file mode 100644 index 00000000..8a33216b --- /dev/null +++ b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/TorrentConfigEdgeCaseTest.kt @@ -0,0 +1,62 @@ +package com.linroid.ketch.torrent + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** + * Additional edge case tests for [TorrentConfig] beyond the basics + * covered in [TorrentConfigTest]. + */ +class TorrentConfigEdgeCaseTest { + + @Test + fun metadataTimeout_zeroSeconds() { + val config = TorrentConfig(metadataTimeoutSeconds = 0) + assertEquals(0.seconds, config.metadataTimeout) + } + + @Test + fun metadataTimeout_largeValue() { + val config = TorrentConfig(metadataTimeoutSeconds = 3600) + assertEquals(3600.seconds, config.metadataTimeout) + } + + @Test + fun customConfig_allFieldsOverridden() { + val config = TorrentConfig( + dhtEnabled = false, + maxActiveTorrents = 10, + metadataTimeoutSeconds = 300, + connectionsPerTorrent = 50, + enableUpload = true, + listenPort = 6881, + ) + assertFalse(config.dhtEnabled) + assertEquals(10, config.maxActiveTorrents) + assertEquals(300, config.metadataTimeoutSeconds) + assertEquals(50, config.connectionsPerTorrent) + assertTrue(config.enableUpload) + assertEquals(6881, config.listenPort) + } + + @Test + fun defaults_dhtEnabled() { + // DHT should be enabled by default for peer discovery + assertTrue(TorrentConfig().dhtEnabled) + } + + @Test + fun defaults_uploadDisabled() { + // Upload (seeding) should be disabled by default for a download + // manager library + assertFalse(TorrentConfig().enableUpload) + } + + @Test + fun defaults_listenPortZero_meansRandom() { + assertEquals(0, TorrentConfig().listenPort) + } +} diff --git a/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/TorrentConfigTest.kt b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/TorrentConfigTest.kt new file mode 100644 index 00000000..5ea3c25b --- /dev/null +++ b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/TorrentConfigTest.kt @@ -0,0 +1,25 @@ +package com.linroid.ketch.torrent + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.seconds + +class TorrentConfigTest { + + @Test + fun metadataTimeout_convertsFromSeconds() { + val config = TorrentConfig(metadataTimeoutSeconds = 60) + assertEquals(60.seconds, config.metadataTimeout) + } + + @Test + fun defaults_areReasonable() { + val config = TorrentConfig() + assertEquals(true, config.dhtEnabled) + assertEquals(5, config.maxActiveTorrents) + assertEquals(120, config.metadataTimeoutSeconds) + assertEquals(100, config.connectionsPerTorrent) + assertEquals(false, config.enableUpload) + assertEquals(0, config.listenPort) + } +} diff --git a/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/TorrentDownloadSourceBuildResumeStateTest.kt b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/TorrentDownloadSourceBuildResumeStateTest.kt new file mode 100644 index 00000000..a01ed176 --- /dev/null +++ b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/TorrentDownloadSourceBuildResumeStateTest.kt @@ -0,0 +1,71 @@ +package com.linroid.ketch.torrent + +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Tests for [TorrentDownloadSource.buildResumeState]. + */ +class TorrentDownloadSourceBuildResumeStateTest { + + @Test + fun buildResumeState_createsValidSourceResumeState() { + val resumeData = byteArrayOf(1, 2, 3, 4, 5) + val state = TorrentDownloadSource.buildResumeState( + infoHash = "aabbccddee11223344556677889900aabbccddee", + totalBytes = 5000L, + resumeData = resumeData, + selectedFileIds = setOf("0", "2"), + savePath = "/downloads/torrent", + ) + assertEquals("torrent", state.sourceType) + + // Verify the data can be deserialized back + val parsed = + Json.decodeFromString(state.data) + assertEquals( + "aabbccddee11223344556677889900aabbccddee", + parsed.infoHash, + ) + assertEquals(5000L, parsed.totalBytes) + assertEquals(setOf("0", "2"), parsed.selectedFileIds) + assertEquals("/downloads/torrent", parsed.savePath) + // resumeData should be base64-encoded, not empty + assertTrue(parsed.resumeData.isNotEmpty()) + } + + @Test + fun buildResumeState_emptyResumeData() { + val state = TorrentDownloadSource.buildResumeState( + infoHash = "0123456789abcdef0123456789abcdef01234567", + totalBytes = 0L, + resumeData = ByteArray(0), + selectedFileIds = emptySet(), + savePath = "/tmp", + ) + assertEquals("torrent", state.sourceType) + + val parsed = + Json.decodeFromString(state.data) + assertEquals(0L, parsed.totalBytes) + assertEquals(emptySet(), parsed.selectedFileIds) + } + + @Test + fun buildResumeState_allFileIdsPreserved() { + val ids = (0 until 50).map { it.toString() }.toSet() + val state = TorrentDownloadSource.buildResumeState( + infoHash = "aabbccddee11223344556677889900aabbccddee", + totalBytes = 100_000L, + resumeData = ByteArray(16) { it.toByte() }, + selectedFileIds = ids, + savePath = "/data", + ) + val parsed = + Json.decodeFromString(state.data) + assertEquals(50, parsed.selectedFileIds.size) + assertEquals(ids, parsed.selectedFileIds) + } +} diff --git a/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/TorrentDownloadSourceEdgeCaseTest.kt b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/TorrentDownloadSourceEdgeCaseTest.kt new file mode 100644 index 00000000..35aa3ca8 --- /dev/null +++ b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/TorrentDownloadSourceEdgeCaseTest.kt @@ -0,0 +1,116 @@ +package com.linroid.ketch.torrent + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Additional edge case tests for [TorrentDownloadSource.canHandle] + * and static properties. + */ +class TorrentDownloadSourceEdgeCaseTest { + + private val source = TorrentDownloadSource() + + // -- canHandle edge cases -- + + @Test + fun canHandle_magnetWithFragment() { + assertTrue( + source.canHandle("magnet:?xt=urn:btih:abc#fragment"), + ) + } + + @Test + fun canHandle_torrentUrlWithMultipleQueryParams() { + assertTrue( + source.canHandle( + "https://example.com/file.torrent?a=1&b=2&c=3", + ), + ) + } + + @Test + fun canHandle_torrentInMiddleOfPath_returnsFalse() { + // ".torrent" is in the filename part but not at the end + assertFalse( + source.canHandle( + "https://example.com/file.torrent.bak", + ), + ) + } + + @Test + fun canHandle_httpUrlEndingInTorrent_noExtension() { + // "torrent" at end without the dot + assertFalse( + source.canHandle("https://example.com/gettorrent"), + ) + } + + @Test + fun canHandle_justMagnetColon() { + // "magnet:" followed by something + assertTrue(source.canHandle("magnet:abc")) + } + + @Test + fun canHandle_magnetUppercase() { + assertTrue( + source.canHandle("MAGNET:?xt=urn:btih:hash"), + ) + } + + @Test + fun canHandle_torrentExtensionUppercase() { + assertTrue( + source.canHandle("https://example.com/FILE.TORRENT"), + ) + } + + @Test + fun canHandle_torrentExtensionMixedCase() { + assertTrue( + source.canHandle("https://example.com/file.Torrent"), + ) + } + + @Test + fun canHandle_ftpTorrentUrl() { + // FTP URL pointing to a .torrent file + assertTrue( + source.canHandle("ftp://example.com/download/file.torrent"), + ) + } + + @Test + fun canHandle_plainHttpUrl_returnsFalse() { + assertFalse(source.canHandle("http://example.com/file.mp4")) + } + + @Test + fun canHandle_dataUrl_returnsFalse() { + assertFalse(source.canHandle("data:text/plain;base64,abc")) + } + + @Test + fun canHandle_blankString_returnsFalse() { + assertFalse(source.canHandle(" ")) + } + + // -- Static properties -- + + @Test + fun type_constantValue() { + assertEquals("torrent", TorrentDownloadSource.TYPE) + } + + @Test + fun managesOwnFileIo_alwaysTrue() { + val s1 = TorrentDownloadSource() + val s2 = TorrentDownloadSource(TorrentConfig()) + assertTrue(s1.managesOwnFileIo) + assertTrue(s2.managesOwnFileIo) + } +} diff --git a/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/TorrentDownloadSourceResolveTest.kt b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/TorrentDownloadSourceResolveTest.kt new file mode 100644 index 00000000..ef5c89a4 --- /dev/null +++ b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/TorrentDownloadSourceResolveTest.kt @@ -0,0 +1,211 @@ +package com.linroid.ketch.torrent + +import com.linroid.ketch.api.FileSelectionMode +import com.linroid.ketch.api.KetchError +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Tests for [TorrentDownloadSource.resolve] using [FakeTorrentEngine]. + */ +class TorrentDownloadSourceResolveTest { + + private val fakeEngine = FakeTorrentEngine() + private val source = TorrentDownloadSource().also { + it.engineFactory = { fakeEngine } + } + + private val sampleHash = + "aabbccddee11223344556677889900aabbccddee" + + private fun singleFileMetadata( + name: String = "test.txt", + size: Long = 1024L, + ) = TorrentMetadata( + infoHash = InfoHash.fromHex(sampleHash), + name = name, + pieceLength = 262144L, + totalBytes = size, + files = listOf( + TorrentMetadata.TorrentFile( + index = 0, + path = name, + size = size, + ), + ), + trackers = listOf("http://tracker.example.com/announce"), + comment = "Test torrent", + ) + + private fun multiFileMetadata() = TorrentMetadata( + infoHash = InfoHash.fromHex(sampleHash), + name = "my-pack", + pieceLength = 65536L, + totalBytes = 3000L, + files = listOf( + TorrentMetadata.TorrentFile(0, "my-pack/a.txt", 1000L), + TorrentMetadata.TorrentFile(1, "my-pack/b.txt", 800L), + TorrentMetadata.TorrentFile(2, "my-pack/c.txt", 1200L), + ), + ) + + // -- resolve via magnet URI -- + + @Test + fun resolve_magnetUri_singleFile() = runTest { + fakeEngine.fetchMetadataResult = singleFileMetadata() + + val resolved = source.resolve( + "magnet:?xt=urn:btih:$sampleHash", + emptyMap(), + ) + + assertTrue(fakeEngine.started) + assertEquals("magnet:?xt=urn:btih:$sampleHash", resolved.url) + assertEquals("torrent", resolved.sourceType) + assertEquals(1024L, resolved.totalBytes) + assertTrue(resolved.supportsResume) + assertEquals("test.txt", resolved.suggestedFileName) + assertEquals(1, resolved.maxSegments) + assertEquals(1, resolved.files.size) + assertEquals("0", resolved.files[0].id) + assertEquals("test.txt", resolved.files[0].name) + assertEquals(1024L, resolved.files[0].size) + assertEquals(FileSelectionMode.MULTIPLE, resolved.selectionMode) + } + + @Test + fun resolve_magnetUri_multiFile() = runTest { + fakeEngine.fetchMetadataResult = multiFileMetadata() + + val resolved = source.resolve( + "magnet:?xt=urn:btih:$sampleHash", + emptyMap(), + ) + + assertEquals(3000L, resolved.totalBytes) + assertEquals("my-pack", resolved.suggestedFileName) + assertEquals(3, resolved.maxSegments) + assertEquals(3, resolved.files.size) + assertEquals("0", resolved.files[0].id) + assertEquals("my-pack/a.txt", resolved.files[0].name) + assertEquals(1000L, resolved.files[0].size) + assertEquals("2", resolved.files[2].id) + assertEquals("my-pack/c.txt", resolved.files[2].name) + assertEquals(1200L, resolved.files[2].size) + } + + @Test + fun resolve_magnetUri_metadataContainsInfoHash() = runTest { + fakeEngine.fetchMetadataResult = singleFileMetadata() + + val resolved = source.resolve( + "magnet:?xt=urn:btih:$sampleHash", + emptyMap(), + ) + + assertEquals(sampleHash, resolved.metadata["infoHash"]) + assertEquals("test.txt", resolved.metadata["name"]) + assertEquals("262144", resolved.metadata["pieceLength"]) + assertEquals("Test torrent", resolved.metadata["comment"]) + } + + @Test + fun resolve_magnetUri_timeoutReturnsNull_throwsNetworkError() = + runTest { + fakeEngine.fetchMetadataResult = null + + val error = assertFailsWith { + source.resolve( + "magnet:?xt=urn:btih:$sampleHash", + emptyMap(), + ) + } + assertNotNull(error.cause) + } + + @Test + fun resolve_magnetUri_engineError_wrapsAsSourceError() = runTest { + fakeEngine.fetchMetadataError = + RuntimeException("Engine failed") + + val error = assertFailsWith { + source.resolve( + "magnet:?xt=urn:btih:$sampleHash", + emptyMap(), + ) + } + assertEquals("torrent", error.sourceType) + assertIs(error.cause) + } + + // -- resolve via .torrent URL -- + + @Test + fun resolve_torrentUrl_throwsSourceError() = runTest { + // Direct .torrent URL fetching is not yet supported + val error = assertFailsWith { + source.resolve( + "https://example.com/file.torrent", + emptyMap(), + ) + } + assertEquals("torrent", error.sourceType) + } + + // -- Engine lifecycle -- + + @Test + fun resolve_startsEngineIfNotRunning() = runTest { + fakeEngine.fetchMetadataResult = singleFileMetadata() + + source.resolve( + "magnet:?xt=urn:btih:$sampleHash", + emptyMap(), + ) + + assertTrue(fakeEngine.started) + assertTrue(fakeEngine.isRunning) + } + + @Test + fun resolve_reusesRunningEngine() = runTest { + fakeEngine.fetchMetadataResult = singleFileMetadata() + + // First call starts engine + source.resolve( + "magnet:?xt=urn:btih:$sampleHash", + emptyMap(), + ) + assertTrue(fakeEngine.started) + + // Second call should reuse the same engine (no stop/restart) + source.resolve( + "magnet:?xt=urn:btih:$sampleHash", + emptyMap(), + ) + // Still the same engine instance + assertTrue(fakeEngine.isRunning) + } + + // -- KetchError passthrough -- + + @Test + fun resolve_ketchErrorPassedThrough_notWrapped() = runTest { + val original = KetchError.AuthenticationFailed("FTP") + fakeEngine.fetchMetadataError = original + + val error = assertFailsWith { + source.resolve( + "magnet:?xt=urn:btih:$sampleHash", + emptyMap(), + ) + } + assertEquals(original, error) + } +} diff --git a/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/TorrentDownloadSourceTest.kt b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/TorrentDownloadSourceTest.kt new file mode 100644 index 00000000..ee0f869e --- /dev/null +++ b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/TorrentDownloadSourceTest.kt @@ -0,0 +1,59 @@ +package com.linroid.ketch.torrent + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TorrentDownloadSourceTest { + + private val source = TorrentDownloadSource() + + @Test + fun canHandle_magnetUri() { + assertTrue(source.canHandle("magnet:?xt=urn:btih:abc123")) + } + + @Test + fun canHandle_magnetUri_caseInsensitive() { + assertTrue(source.canHandle("MAGNET:?xt=urn:btih:abc123")) + } + + @Test + fun canHandle_torrentUrl() { + assertTrue( + source.canHandle("https://example.com/file.torrent"), + ) + } + + @Test + fun canHandle_torrentUrlWithQueryParams() { + assertTrue( + source.canHandle("https://example.com/file.torrent?key=val"), + ) + } + + @Test + fun canHandle_httpUrl_returnsFalse() { + assertFalse(source.canHandle("https://example.com/file.zip")) + } + + @Test + fun canHandle_ftpUrl_returnsFalse() { + assertFalse(source.canHandle("ftp://example.com/file.zip")) + } + + @Test + fun canHandle_emptyString_returnsFalse() { + assertFalse(source.canHandle("")) + } + + @Test + fun type_isTorrent() { + kotlin.test.assertEquals("torrent", source.type) + } + + @Test + fun managesOwnFileIo_isTrue() { + assertTrue(source.managesOwnFileIo) + } +} diff --git a/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/TorrentMetadataEdgeCaseTest.kt b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/TorrentMetadataEdgeCaseTest.kt new file mode 100644 index 00000000..da431ccf --- /dev/null +++ b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/TorrentMetadataEdgeCaseTest.kt @@ -0,0 +1,345 @@ +package com.linroid.ketch.torrent + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +/** + * Additional edge case tests for [TorrentMetadata.fromBencode] + * beyond the basics covered in [TorrentMetadataTest]. + */ +class TorrentMetadataEdgeCaseTest { + + private fun buildTorrent( + info: Map, + vararg extra: Pair, + ): ByteArray { + val torrent = mutableMapOf("info" to info) + extra.forEach { (k, v) -> torrent[k] = v } + return Bencode.encode(torrent) + } + + private fun singleFileInfo( + name: String = "test.txt", + pieceLength: Long = 262144L, + length: Long = 1024L, + ): Map = mapOf( + "name" to name, + "piece length" to pieceLength, + "length" to length, + "pieces" to ByteArray(20), + ) + + // -- Single file edge cases -- + + @Test + fun fromBencode_singleFile_zeroBytes() { + val data = buildTorrent(singleFileInfo(length = 0L)) + val metadata = TorrentMetadata.fromBencode(data) + assertEquals(0L, metadata.totalBytes) + assertEquals(1, metadata.files.size) + assertEquals(0L, metadata.files[0].size) + } + + @Test + fun fromBencode_singleFile_largeSize() { + val size = 10_000_000_000L // 10 GB + val data = buildTorrent(singleFileInfo(length = size)) + val metadata = TorrentMetadata.fromBencode(data) + assertEquals(size, metadata.totalBytes) + } + + @Test + fun fromBencode_singleFile_missingLength_throws() { + val info = mapOf( + "name" to "test.txt", + "piece length" to 256L, + "pieces" to ByteArray(20), + // no "length" and no "files" + ) + val data = buildTorrent(info) + assertFailsWith { + TorrentMetadata.fromBencode(data) + } + } + + // -- Multi file edge cases -- + + @Test + fun fromBencode_multiFile_singleFileInList() { + val files = listOf( + mapOf( + "path" to listOf("readme.txt".encodeToByteArray()), + "length" to 100L, + ), + ) + val info = mapOf( + "name" to "my-dir", + "piece length" to 1024L, + "files" to files, + "pieces" to ByteArray(20), + ) + val data = buildTorrent(info) + val metadata = TorrentMetadata.fromBencode(data) + assertEquals(1, metadata.files.size) + assertEquals("my-dir/readme.txt", metadata.files[0].path) + assertEquals(100L, metadata.totalBytes) + } + + @Test + fun fromBencode_multiFile_totalBytesIsSumOfAllFiles() { + val files = listOf( + mapOf( + "path" to listOf("a.txt".encodeToByteArray()), + "length" to 100L, + ), + mapOf( + "path" to listOf("b.txt".encodeToByteArray()), + "length" to 200L, + ), + mapOf( + "path" to listOf("c.txt".encodeToByteArray()), + "length" to 300L, + ), + ) + val info = mapOf( + "name" to "pack", + "piece length" to 1024L, + "files" to files, + "pieces" to ByteArray(20), + ) + val data = buildTorrent(info) + val metadata = TorrentMetadata.fromBencode(data) + assertEquals(600L, metadata.totalBytes) + assertEquals(3, metadata.files.size) + } + + @Test + fun fromBencode_multiFile_deeplyNestedPath() { + val files = listOf( + mapOf( + "path" to listOf( + "dir1".encodeToByteArray(), + "dir2".encodeToByteArray(), + "dir3".encodeToByteArray(), + "file.txt".encodeToByteArray(), + ), + "length" to 42L, + ), + ) + val info = mapOf( + "name" to "root", + "piece length" to 256L, + "files" to files, + "pieces" to ByteArray(20), + ) + val data = buildTorrent(info) + val metadata = TorrentMetadata.fromBencode(data) + assertEquals( + "root/dir1/dir2/dir3/file.txt", + metadata.files[0].path, + ) + } + + @Test + fun fromBencode_multiFile_missingPathInEntry_throws() { + val files = listOf( + mapOf("length" to 100L), // missing "path" + ) + val info = mapOf( + "name" to "pack", + "piece length" to 1024L, + "files" to files, + "pieces" to ByteArray(20), + ) + val data = buildTorrent(info) + assertFailsWith { + TorrentMetadata.fromBencode(data) + } + } + + @Test + fun fromBencode_multiFile_missingLengthInEntry_throws() { + val files = listOf( + mapOf( + "path" to listOf("file.txt".encodeToByteArray()), + // missing "length" + ), + ) + val info = mapOf( + "name" to "pack", + "piece length" to 1024L, + "files" to files, + "pieces" to ByteArray(20), + ) + val data = buildTorrent(info) + assertFailsWith { + TorrentMetadata.fromBencode(data) + } + } + + @Test + fun fromBencode_multiFile_fileIndicesAreSequential() { + val files = listOf( + mapOf( + "path" to listOf("a.txt".encodeToByteArray()), + "length" to 10L, + ), + mapOf( + "path" to listOf("b.txt".encodeToByteArray()), + "length" to 20L, + ), + mapOf( + "path" to listOf("c.txt".encodeToByteArray()), + "length" to 30L, + ), + ) + val info = mapOf( + "name" to "pack", + "piece length" to 256L, + "files" to files, + "pieces" to ByteArray(20), + ) + val data = buildTorrent(info) + val metadata = TorrentMetadata.fromBencode(data) + assertEquals(0, metadata.files[0].index) + assertEquals(1, metadata.files[1].index) + assertEquals(2, metadata.files[2].index) + } + + // -- Tracker parsing edge cases -- + + @Test + fun fromBencode_noTrackers() { + val data = buildTorrent(singleFileInfo()) + val metadata = TorrentMetadata.fromBencode(data) + assertEquals(0, metadata.trackers.size) + } + + @Test + fun fromBencode_announceOnly() { + val data = buildTorrent( + singleFileInfo(), + "announce" to "http://tracker.example.com/announce", + ) + val metadata = TorrentMetadata.fromBencode(data) + assertEquals(1, metadata.trackers.size) + assertEquals( + "http://tracker.example.com/announce", + metadata.trackers[0], + ) + } + + @Test + fun fromBencode_announceListOverridesAnnounce() { + // When both "announce" and "announce-list" present, + // announce-list is used + val announceList = listOf( + listOf("http://t1.com".encodeToByteArray()), + listOf("http://t2.com".encodeToByteArray()), + ) + val data = buildTorrent( + singleFileInfo(), + "announce" to "http://ignored.com", + "announce-list" to announceList, + ) + val metadata = TorrentMetadata.fromBencode(data) + assertEquals(2, metadata.trackers.size) + assertEquals("http://t1.com", metadata.trackers[0]) + assertEquals("http://t2.com", metadata.trackers[1]) + } + + @Test + fun fromBencode_announceList_multiTier_flattened() { + val announceList = listOf( + listOf( + "http://tier1-a.com".encodeToByteArray(), + "http://tier1-b.com".encodeToByteArray(), + ), + listOf("http://tier2.com".encodeToByteArray()), + ) + val data = buildTorrent( + singleFileInfo(), + "announce-list" to announceList, + ) + val metadata = TorrentMetadata.fromBencode(data) + assertEquals(3, metadata.trackers.size) + } + + // -- Optional fields -- + + @Test + fun fromBencode_noComment() { + val data = buildTorrent(singleFileInfo()) + val metadata = TorrentMetadata.fromBencode(data) + assertNull(metadata.comment) + assertNull(metadata.createdBy) + } + + @Test + fun fromBencode_commentAndCreatedBy() { + val data = buildTorrent( + singleFileInfo(), + "comment" to "My Comment", + "created by" to "TestTool v1.0", + ) + val metadata = TorrentMetadata.fromBencode(data) + assertEquals("My Comment", metadata.comment) + assertEquals("TestTool v1.0", metadata.createdBy) + } + + // -- Root-level validation -- + + @Test + fun fromBencode_rootNotDictionary_throws() { + // Root is a list, not a dict + val data = Bencode.encode(listOf("not", "a", "dict")) + assertFailsWith { + TorrentMetadata.fromBencode(data) + } + } + + @Test + fun fromBencode_rootIsInteger_throws() { + val data = Bencode.encode(42L) + assertFailsWith { + TorrentMetadata.fromBencode(data) + } + } + + @Test + fun fromBencode_rootIsString_throws() { + val data = Bencode.encode("not a dict") + assertFailsWith { + TorrentMetadata.fromBencode(data) + } + } + + // -- Info hash consistency -- + + @Test + fun fromBencode_differentInfoDicts_differentHashes() { + val data1 = buildTorrent(singleFileInfo(name = "file-a.txt")) + val data2 = buildTorrent(singleFileInfo(name = "file-b.txt")) + val meta1 = TorrentMetadata.fromBencode(data1) + val meta2 = TorrentMetadata.fromBencode(data2) + // Different info dicts must produce different info hashes + assertTrue(meta1.infoHash != meta2.infoHash) + } + + @Test + fun fromBencode_sameInfoDict_differentOuterFields_sameHash() { + val info = singleFileInfo() + val data1 = buildTorrent(info, "comment" to "comment1") + val data2 = buildTorrent(info, "comment" to "comment2") + val meta1 = TorrentMetadata.fromBencode(data1) + val meta2 = TorrentMetadata.fromBencode(data2) + // Info hash is derived only from info dict + assertEquals(meta1.infoHash, meta2.infoHash) + } + + private fun assertTrue(condition: Boolean) { + kotlin.test.assertTrue(condition) + } +} diff --git a/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/TorrentMetadataTest.kt b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/TorrentMetadataTest.kt new file mode 100644 index 00000000..073704c6 --- /dev/null +++ b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/TorrentMetadataTest.kt @@ -0,0 +1,176 @@ +package com.linroid.ketch.torrent + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class TorrentMetadataTest { + + @Test + fun fromBencode_singleFileTorrent() { + val info = mapOf( + "name" to "test.txt", + "piece length" to 262144L, + "length" to 1024L, + "pieces" to ByteArray(20), + ) + val torrent = mapOf( + "info" to info, + "announce" to "http://tracker.example.com/announce", + ) + val data = Bencode.encode(torrent) + val metadata = TorrentMetadata.fromBencode(data) + + assertEquals("test.txt", metadata.name) + assertEquals(262144L, metadata.pieceLength) + assertEquals(1024L, metadata.totalBytes) + assertEquals(1, metadata.files.size) + assertEquals("test.txt", metadata.files[0].path) + assertEquals(1024L, metadata.files[0].size) + assertEquals(0, metadata.files[0].index) + assertEquals(1, metadata.trackers.size) + assertEquals( + "http://tracker.example.com/announce", + metadata.trackers[0], + ) + } + + @Test + fun fromBencode_multiFileTorrent() { + val files = listOf( + mapOf( + "path" to listOf( + "dir".encodeToByteArray(), + "file1.txt".encodeToByteArray(), + ), + "length" to 500L, + ), + mapOf( + "path" to listOf("file2.txt".encodeToByteArray()), + "length" to 300L, + ), + ) + val info = mapOf( + "name" to "my-torrent", + "piece length" to 65536L, + "files" to files, + "pieces" to ByteArray(20), + ) + val torrent = mapOf("info" to info) + val data = Bencode.encode(torrent) + val metadata = TorrentMetadata.fromBencode(data) + + assertEquals("my-torrent", metadata.name) + assertEquals(800L, metadata.totalBytes) + assertEquals(2, metadata.files.size) + assertEquals("my-torrent/dir/file1.txt", metadata.files[0].path) + assertEquals(500L, metadata.files[0].size) + assertEquals("my-torrent/file2.txt", metadata.files[1].path) + assertEquals(300L, metadata.files[1].size) + } + + @Test + fun fromBencode_announceList() { + val info = mapOf( + "name" to "test", + "piece length" to 1024L, + "length" to 100L, + "pieces" to ByteArray(20), + ) + val announceList = listOf( + listOf( + "http://t1.example.com/announce".encodeToByteArray(), + "http://t2.example.com/announce".encodeToByteArray(), + ), + listOf("udp://t3.example.com:6881".encodeToByteArray()), + ) + val torrent = mapOf( + "info" to info, + "announce-list" to announceList, + ) + val data = Bencode.encode(torrent) + val metadata = TorrentMetadata.fromBencode(data) + + assertEquals(3, metadata.trackers.size) + assertEquals( + "http://t1.example.com/announce", + metadata.trackers[0], + ) + } + + @Test + fun fromBencode_infoHashIsConsistent() { + val info = mapOf( + "name" to "test", + "piece length" to 256L, + "length" to 10L, + "pieces" to ByteArray(20), + ) + val torrent = mapOf("info" to info) + val data = Bencode.encode(torrent) + + val metadata1 = TorrentMetadata.fromBencode(data) + val metadata2 = TorrentMetadata.fromBencode(data) + + assertEquals(metadata1.infoHash, metadata2.infoHash) + assertEquals(40, metadata1.infoHash.hex.length) + } + + @Test + fun fromBencode_missingInfo_throws() { + val torrent = mapOf("announce" to "http://tracker.example.com") + val data = Bencode.encode(torrent) + + assertFailsWith { + TorrentMetadata.fromBencode(data) + } + } + + @Test + fun fromBencode_missingName_throws() { + val info = mapOf( + "piece length" to 256L, + "length" to 10L, + ) + val torrent = mapOf("info" to info) + val data = Bencode.encode(torrent) + + assertFailsWith { + TorrentMetadata.fromBencode(data) + } + } + + @Test + fun fromBencode_missingPieceLength_throws() { + val info = mapOf( + "name" to "test", + "length" to 10L, + ) + val torrent = mapOf("info" to info) + val data = Bencode.encode(torrent) + + assertFailsWith { + TorrentMetadata.fromBencode(data) + } + } + + @Test + fun fromBencode_comment() { + val info = mapOf( + "name" to "test", + "piece length" to 256L, + "length" to 10L, + "pieces" to ByteArray(20), + ) + val torrent = mapOf( + "info" to info, + "comment" to "Test torrent", + "created by" to "Ketch Test", + ) + val data = Bencode.encode(torrent) + val metadata = TorrentMetadata.fromBencode(data) + + assertEquals("Test torrent", metadata.comment) + assertEquals("Ketch Test", metadata.createdBy) + } +} diff --git a/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/TorrentResumeStateEdgeCaseTest.kt b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/TorrentResumeStateEdgeCaseTest.kt new file mode 100644 index 00000000..d72e675f --- /dev/null +++ b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/TorrentResumeStateEdgeCaseTest.kt @@ -0,0 +1,125 @@ +package com.linroid.ketch.torrent + +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +/** + * Additional edge case tests for [TorrentResumeState] serialization + * beyond the basics covered in [TorrentResumeStateTest]. + */ +class TorrentResumeStateEdgeCaseTest { + + private val lenientJson = Json { ignoreUnknownKeys = true } + + @Test + fun deserialization_unknownFields_ignoredWithLenientJson() { + val json = """ + { + "infoHash": "aabbccddee11223344556677889900aabbccddee", + "totalBytes": 1000, + "resumeData": "base64data", + "selectedFileIds": ["0"], + "savePath": "/tmp", + "futureField": "unknown" + } + """.trimIndent() + val state = lenientJson.decodeFromString(json) + assertEquals( + "aabbccddee11223344556677889900aabbccddee", + state.infoHash, + ) + assertEquals(1000L, state.totalBytes) + } + + @Test + fun deserialization_missingRequiredField_throws() { + // Missing "savePath" + val json = """ + { + "infoHash": "aabbccddee11223344556677889900aabbccddee", + "totalBytes": 1000, + "resumeData": "base64data", + "selectedFileIds": ["0"] + } + """.trimIndent() + assertFailsWith { + Json.decodeFromString(json) + } + } + + @Test + fun serialization_largeResumeData() { + val largeData = "A".repeat(100_000) + val state = TorrentResumeState( + infoHash = "aabbccddee11223344556677889900aabbccddee", + totalBytes = 500L, + resumeData = largeData, + selectedFileIds = setOf("0"), + savePath = "/tmp", + ) + val json = Json.encodeToString(state) + val decoded = Json.decodeFromString(json) + assertEquals(largeData, decoded.resumeData) + } + + @Test + fun serialization_manySelectedFileIds() { + val ids = (0 until 100).map { it.toString() }.toSet() + val state = TorrentResumeState( + infoHash = "aabbccddee11223344556677889900aabbccddee", + totalBytes = 10_000L, + resumeData = "data", + selectedFileIds = ids, + savePath = "/downloads", + ) + val json = Json.encodeToString(state) + val decoded = Json.decodeFromString(json) + assertEquals(100, decoded.selectedFileIds.size) + assertEquals(ids, decoded.selectedFileIds) + } + + @Test + fun serialization_specialCharsInSavePath() { + val path = "/path/with spaces/and-special_chars/日本語" + val state = TorrentResumeState( + infoHash = "aabbccddee11223344556677889900aabbccddee", + totalBytes = 0, + resumeData = "", + selectedFileIds = emptySet(), + savePath = path, + ) + val json = Json.encodeToString(state) + val decoded = Json.decodeFromString(json) + assertEquals(path, decoded.savePath) + } + + @Test + fun serialization_zeroTotalBytes() { + val state = TorrentResumeState( + infoHash = "aabbccddee11223344556677889900aabbccddee", + totalBytes = 0L, + resumeData = "", + selectedFileIds = emptySet(), + savePath = "/tmp", + ) + val json = Json.encodeToString(state) + val decoded = Json.decodeFromString(json) + assertEquals(0L, decoded.totalBytes) + } + + @Test + fun serialization_maxLongTotalBytes() { + val state = TorrentResumeState( + infoHash = "aabbccddee11223344556677889900aabbccddee", + totalBytes = Long.MAX_VALUE, + resumeData = "data", + selectedFileIds = setOf("0"), + savePath = "/tmp", + ) + val json = Json.encodeToString(state) + val decoded = Json.decodeFromString(json) + assertEquals(Long.MAX_VALUE, decoded.totalBytes) + } +} diff --git a/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/TorrentResumeStateTest.kt b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/TorrentResumeStateTest.kt new file mode 100644 index 00000000..d3418deb --- /dev/null +++ b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/TorrentResumeStateTest.kt @@ -0,0 +1,50 @@ +package com.linroid.ketch.torrent + +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals + +class TorrentResumeStateTest { + + @Test + fun serialization_roundtrip() { + val state = TorrentResumeState( + infoHash = "aabbccddee11223344556677889900aabbccddee", + totalBytes = 1024000, + resumeData = "base64encodeddata==", + selectedFileIds = setOf("0", "2", "5"), + savePath = "/downloads/torrent", + ) + val json = Json.encodeToString(state) + val decoded = Json.decodeFromString(json) + assertEquals(state, decoded) + } + + @Test + fun deserialization_preservesSelectedFileIds() { + val state = TorrentResumeState( + infoHash = "0123456789abcdef0123456789abcdef01234567", + totalBytes = 5000, + resumeData = "data", + selectedFileIds = setOf("1", "3"), + savePath = "/tmp", + ) + val json = Json.encodeToString(state) + val decoded = Json.decodeFromString(json) + assertEquals(setOf("1", "3"), decoded.selectedFileIds) + } + + @Test + fun deserialization_emptySelectedFileIds() { + val state = TorrentResumeState( + infoHash = "0123456789abcdef0123456789abcdef01234567", + totalBytes = 0, + resumeData = "", + selectedFileIds = emptySet(), + savePath = "/tmp", + ) + val json = Json.encodeToString(state) + val decoded = Json.decodeFromString(json) + assertEquals(emptySet(), decoded.selectedFileIds) + } +} diff --git a/library/torrent/src/iosMain/kotlin/com/linroid/ketch/torrent/PlatformActuals.ios.kt b/library/torrent/src/iosMain/kotlin/com/linroid/ketch/torrent/PlatformActuals.ios.kt new file mode 100644 index 00000000..9af4baf2 --- /dev/null +++ b/library/torrent/src/iosMain/kotlin/com/linroid/ketch/torrent/PlatformActuals.ios.kt @@ -0,0 +1,52 @@ +package com.linroid.ketch.torrent + +import com.linroid.ketch.api.KetchError +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.allocArrayOf +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.usePinned +import platform.Foundation.NSData +import platform.Foundation.base64EncodedStringWithOptions +import platform.Foundation.create +import platform.Foundation.dataWithBytes +import platform.posix.memcpy + +internal actual fun createTorrentEngine( + config: TorrentConfig, +): TorrentEngine { + throw KetchError.Unsupported( + cause = UnsupportedOperationException( + "BitTorrent is not yet supported on iOS" + ), + ) +} + +@OptIn(ExperimentalForeignApi::class) +internal actual fun encodeBase64(data: ByteArray): String { + if (data.isEmpty()) return "" + memScoped { + val nsData = NSData.dataWithBytes( + allocArrayOf(data), + data.size.toULong(), + ) + return nsData.base64EncodedStringWithOptions(0u) + } +} + +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) +internal actual fun decodeBase64(data: String): ByteArray { + if (data.isEmpty()) return ByteArray(0) + val nsData: NSData = NSData.create( + base64EncodedString = data, + options = 0u, + ) ?: throw IllegalArgumentException("Invalid base64 string") + val size = nsData.length.toInt() + if (size == 0) return ByteArray(0) + val result = ByteArray(size) + result.usePinned { pinned -> + memcpy(pinned.addressOf(0), nsData.bytes, nsData.length) + } + return result +} diff --git a/library/torrent/src/iosMain/kotlin/com/linroid/ketch/torrent/Sha1.ios.kt b/library/torrent/src/iosMain/kotlin/com/linroid/ketch/torrent/Sha1.ios.kt new file mode 100644 index 00000000..8d410ec9 --- /dev/null +++ b/library/torrent/src/iosMain/kotlin/com/linroid/ketch/torrent/Sha1.ios.kt @@ -0,0 +1,22 @@ +package com.linroid.ketch.torrent + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.allocArrayOf +import kotlinx.cinterop.get +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.refTo +import kotlinx.cinterop.usePinned +import platform.CoreCrypto.CC_SHA1 +import platform.CoreCrypto.CC_SHA1_DIGEST_LENGTH + +@OptIn(ExperimentalForeignApi::class) +internal actual fun sha1Digest(data: ByteArray): ByteArray { + val digest = UByteArray(CC_SHA1_DIGEST_LENGTH) + data.usePinned { pinned -> + digest.usePinned { digestPinned -> + CC_SHA1(pinned.addressOf(0), data.size.toUInt(), digestPinned.addressOf(0)) + } + } + return digest.toByteArray() +} diff --git a/library/torrent/src/jvmAndAndroidMain/kotlin/com/linroid/ketch/torrent/Libtorrent4jEngine.kt b/library/torrent/src/jvmAndAndroidMain/kotlin/com/linroid/ketch/torrent/Libtorrent4jEngine.kt new file mode 100644 index 00000000..fde847a1 --- /dev/null +++ b/library/torrent/src/jvmAndAndroidMain/kotlin/com/linroid/ketch/torrent/Libtorrent4jEngine.kt @@ -0,0 +1,290 @@ +package com.linroid.ketch.torrent + +import com.linroid.ketch.api.log.KetchLogger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.libtorrent4j.AlertListener +import org.libtorrent4j.Priority +import org.libtorrent4j.SessionHandle +import org.libtorrent4j.SessionManager +import org.libtorrent4j.SessionParams +import org.libtorrent4j.SettingsPack +import org.libtorrent4j.TorrentInfo +import org.libtorrent4j.alerts.Alert +import org.libtorrent4j.alerts.AlertType +import org.libtorrent4j.alerts.MetadataReceivedAlert +import org.libtorrent4j.swig.settings_pack +import java.io.File +import kotlin.coroutines.resume + +/** + * [TorrentEngine] implementation using libtorrent4j. + * + * Wraps [SessionManager] to manage the libtorrent session lifecycle, + * add/remove torrents, and fetch metadata from magnet links. + */ +internal class Libtorrent4jEngine( + private val config: TorrentConfig, +) : TorrentEngine { + + private val log = KetchLogger("TorrentEngine") + private var session: SessionManager? = null + private val sessions = mutableMapOf() + + override val isRunning: Boolean + get() = session?.isRunning == true + + override suspend fun start() = withContext(Dispatchers.IO) { + if (isRunning) return@withContext + log.i { "Starting torrent engine" } + + val settings = SettingsPack() + settings.setString( + settings_pack.string_types.dht_bootstrap_nodes.swigValue(), + DHT_BOOTSTRAP_NODES, + ) + settings.setBoolean( + settings_pack.bool_types.enable_dht.swigValue(), + config.dhtEnabled, + ) + settings.setInteger( + settings_pack.int_types.active_downloads.swigValue(), + config.maxActiveTorrents, + ) + settings.setInteger( + settings_pack.int_types.active_seeds.swigValue(), + if (config.enableUpload) config.maxActiveTorrents else 0, + ) + if (config.listenPort > 0) { + settings.setString( + settings_pack.string_types.listen_interfaces.swigValue(), + "0.0.0.0:${config.listenPort}", + ) + } + + val params = SessionParams(settings) + val mgr = SessionManager(config.enableUpload) + mgr.start(params) + session = mgr + log.i { "Torrent engine started" } + } + + override suspend fun stop() = withContext(Dispatchers.IO) { + log.i { "Stopping torrent engine" } + sessions.clear() + session?.stop() + session = null + log.i { "Torrent engine stopped" } + } + + override suspend fun fetchMetadata( + magnetUri: String, + ): TorrentMetadata? = withContext(Dispatchers.IO) { + val mgr = requireSession() + log.d { "Fetching metadata for magnet URI" } + + val torrentInfo = try { + withTimeout(config.metadataTimeout) { + suspendCancellableCoroutine { cont -> + val listener = object : AlertListener { + override fun types(): IntArray = intArrayOf( + AlertType.METADATA_RECEIVED.swig(), + ) + + override fun alert(alert: Alert<*>) { + if (alert is MetadataReceivedAlert) { + mgr.removeListener(this) + val ti = alert.handle().torrentFile() + cont.resume(ti) + } + } + } + mgr.addListener(listener) + cont.invokeOnCancellation { + mgr.removeListener(listener) + } + val tempDir = File( + System.getProperty("java.io.tmpdir"), + "ketch-torrent", + ) + if (!tempDir.exists()) tempDir.mkdirs() + mgr.fetchMagnet( + magnetUri, config.metadataTimeoutSeconds, tempDir, + ) + } + } + } catch (e: Exception) { + log.w(e) { "Metadata fetch failed or timed out" } + return@withContext null + } + + if (torrentInfo == null) return@withContext null + + mapTorrentInfo(torrentInfo) + } + + override suspend fun addTorrent( + infoHash: String, + savePath: String, + magnetUri: String?, + torrentData: ByteArray?, + selectedFileIndices: Set, + resumeData: ByteArray?, + ): TorrentSession = withContext(Dispatchers.IO) { + val mgr = requireSession() + log.i { + "Adding torrent: infoHash=$infoHash, savePath=$savePath" + } + + val saveDir = File(savePath) + if (!saveDir.exists()) saveDir.mkdirs() + + val handle = when { + torrentData != null -> { + val ti = TorrentInfo(torrentData) + mgr.download(ti, saveDir) + mgr.find(ti.infoHash()) + ?: throw IllegalStateException( + "Torrent handle not found after add" + ) + } + magnetUri != null -> { + val tempDir = File( + System.getProperty("java.io.tmpdir"), + "ketch-torrent", + ) + if (!tempDir.exists()) tempDir.mkdirs() + mgr.fetchMagnet( + magnetUri, config.metadataTimeoutSeconds, tempDir, + ) + // Wait for the handle to become available + val sha1 = + org.libtorrent4j.Sha1Hash.parseHex(infoHash) + var found = mgr.find(sha1) + var attempts = 0 + while (found == null && attempts < 50) { + Thread.sleep(100) + found = mgr.find(sha1) + attempts++ + } + found ?: throw IllegalStateException( + "Could not find torrent handle for $infoHash" + ) + } + else -> throw IllegalArgumentException( + "Either magnetUri or torrentData must be provided" + ) + } + + // Apply file selection + if (selectedFileIndices.isNotEmpty() && + handle.torrentFile() != null + ) { + val numFiles = handle.torrentFile().numFiles() + val priorities = Array(numFiles) { idx -> + if (idx in selectedFileIndices) { + Priority.DEFAULT + } else { + Priority.IGNORE + } + } + handle.prioritizeFiles(priorities) + } + + // Resume if we have previous session data + if (resumeData != null) { + handle.resume() + } + + val totalBytes = if (handle.torrentFile() != null) { + if (selectedFileIndices.isEmpty()) { + handle.torrentFile().totalSize() + } else { + selectedFileIndices.sumOf { idx -> + if (idx < handle.torrentFile().numFiles()) { + handle.torrentFile().files().fileSize(idx) + } else { + 0L + } + } + } + } else { + 0L + } + + val torrentSession = Libtorrent4jSession( + handle, infoHash, totalBytes, + ) + sessions[infoHash] = torrentSession + torrentSession + } + + override suspend fun removeTorrent( + infoHash: String, + deleteFiles: Boolean, + ) = withContext(Dispatchers.IO) { + val mgr = requireSession() + val sha1 = org.libtorrent4j.Sha1Hash.parseHex(infoHash) + val handle = mgr.find(sha1) + if (handle != null) { + if (deleteFiles) { + mgr.remove(handle, SessionHandle.DELETE_FILES) + } else { + mgr.remove(handle) + } + } + sessions.remove(infoHash) + log.i { "Removed torrent: infoHash=$infoHash" } + } + + override fun setDownloadRateLimit(bytesPerSecond: Long) { + val mgr = session ?: return + mgr.downloadRateLimit(bytesPerSecond.toInt()) + log.d { + "Global download rate limit set to $bytesPerSecond B/s" + } + } + + override fun setUploadRateLimit(bytesPerSecond: Long) { + val mgr = session ?: return + mgr.uploadRateLimit(bytesPerSecond.toInt()) + log.d { + "Global upload rate limit set to $bytesPerSecond B/s" + } + } + + private fun requireSession(): SessionManager { + return session ?: throw IllegalStateException( + "Torrent engine not started" + ) + } + + private fun mapTorrentInfo(ti: TorrentInfo): TorrentMetadata { + val fileStorage = ti.files() + val files = (0 until fileStorage.numFiles()).map { i -> + TorrentMetadata.TorrentFile( + index = i, + path = fileStorage.filePath(i), + size = fileStorage.fileSize(i), + ) + } + + return TorrentMetadata( + infoHash = InfoHash.fromHex(ti.infoHash().toHex()), + name = ti.name(), + pieceLength = ti.pieceLength().toLong(), + totalBytes = ti.totalSize(), + files = files, + ) + } + + companion object { + private const val DHT_BOOTSTRAP_NODES = + "router.bittorrent.com:6881," + + "router.utorrent.com:6881," + + "dht.transmissionbt.com:6881," + + "dht.aelitis.com:6881" + } +} diff --git a/library/torrent/src/jvmAndAndroidMain/kotlin/com/linroid/ketch/torrent/Libtorrent4jSession.kt b/library/torrent/src/jvmAndAndroidMain/kotlin/com/linroid/ketch/torrent/Libtorrent4jSession.kt new file mode 100644 index 00000000..32055b42 --- /dev/null +++ b/library/torrent/src/jvmAndAndroidMain/kotlin/com/linroid/ketch/torrent/Libtorrent4jSession.kt @@ -0,0 +1,104 @@ +package com.linroid.ketch.torrent + +import com.linroid.ketch.api.log.KetchLogger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.withContext +import org.libtorrent4j.TorrentHandle +import org.libtorrent4j.TorrentStatus + +/** + * [TorrentSession] implementation wrapping a libtorrent4j + * [TorrentHandle]. + */ +internal class Libtorrent4jSession( + private val handle: TorrentHandle, + override val infoHash: String, + override val totalBytes: Long, +) : TorrentSession { + + private val log = KetchLogger("TorrentSession") + + private val _downloadedBytes = MutableStateFlow(0L) + override val downloadedBytes: StateFlow = _downloadedBytes + + private val _state = + MutableStateFlow(TorrentSessionState.CHECKING_METADATA) + override val state: StateFlow = _state + + override val downloadSpeed: Long + get() { + updateStatus() + return handle.status().downloadRate().toLong() + } + + override suspend fun pause() = withContext(Dispatchers.IO) { + handle.pause() + _state.value = TorrentSessionState.PAUSED + log.d { "Paused torrent: $infoHash" } + } + + override suspend fun resume() = withContext(Dispatchers.IO) { + handle.resume() + _state.value = TorrentSessionState.DOWNLOADING + log.d { "Resumed torrent: $infoHash" } + } + + override fun setFilePriorities(priorities: Map) { + for ((index, priority) in priorities) { + handle.filePriority(index, PRIORITY_MAP[priority] ?: continue) + } + } + + override fun setDownloadRateLimit(bytesPerSecond: Long) { + handle.setDownloadLimit(bytesPerSecond.toInt()) + log.d { "Download rate limit for $infoHash: $bytesPerSecond B/s" } + } + + override suspend fun saveResumeData(): ByteArray? = + withContext(Dispatchers.IO) { + try { + handle.saveResumeData() + // libtorrent4j delivers resume data asynchronously via + // alert; for simplicity we return null here and let the + // engine handle resume data persistence via alerts + null + } catch (e: Exception) { + log.w(e) { "Failed to save resume data for $infoHash" } + null + } + } + + internal fun updateStatus() { + val status = handle.status() + _downloadedBytes.value = status.totalDone() + _state.value = mapState(status.state()) + } + + private fun mapState( + state: TorrentStatus.State, + ): TorrentSessionState = when (state) { + TorrentStatus.State.CHECKING_FILES -> + TorrentSessionState.CHECKING_FILES + TorrentStatus.State.DOWNLOADING_METADATA -> + TorrentSessionState.CHECKING_METADATA + TorrentStatus.State.DOWNLOADING -> + TorrentSessionState.DOWNLOADING + TorrentStatus.State.FINISHED -> + TorrentSessionState.FINISHED + TorrentStatus.State.SEEDING -> + TorrentSessionState.SEEDING + TorrentStatus.State.CHECKING_RESUME_DATA -> + TorrentSessionState.CHECKING_FILES + else -> TorrentSessionState.STOPPED + } + + companion object { + private val PRIORITY_MAP = mapOf( + 0 to org.libtorrent4j.Priority.IGNORE, + 4 to org.libtorrent4j.Priority.DEFAULT, + 7 to org.libtorrent4j.Priority.TOP_PRIORITY, + ) + } +} diff --git a/library/torrent/src/jvmAndAndroidMain/kotlin/com/linroid/ketch/torrent/PlatformActuals.jvmAndAndroid.kt b/library/torrent/src/jvmAndAndroidMain/kotlin/com/linroid/ketch/torrent/PlatformActuals.jvmAndAndroid.kt new file mode 100644 index 00000000..e53f2db8 --- /dev/null +++ b/library/torrent/src/jvmAndAndroidMain/kotlin/com/linroid/ketch/torrent/PlatformActuals.jvmAndAndroid.kt @@ -0,0 +1,21 @@ +package com.linroid.ketch.torrent + +import java.util.Base64 + +/** + * Creates the libtorrent4j-based [TorrentEngine]. + * + * Called by the leaf `actual fun createTorrentEngine` in + * `jvmMain` and `androidMain`. + */ +internal fun createLibtorrent4jEngine( + config: TorrentConfig, +): TorrentEngine = Libtorrent4jEngine(config) + +internal fun jvmEncodeBase64(data: ByteArray): String { + return Base64.getEncoder().encodeToString(data) +} + +internal fun jvmDecodeBase64(data: String): ByteArray { + return Base64.getDecoder().decode(data) +} diff --git a/library/torrent/src/jvmAndAndroidMain/kotlin/com/linroid/ketch/torrent/Sha1.jvmAndAndroid.kt b/library/torrent/src/jvmAndAndroidMain/kotlin/com/linroid/ketch/torrent/Sha1.jvmAndAndroid.kt new file mode 100644 index 00000000..d62786fc --- /dev/null +++ b/library/torrent/src/jvmAndAndroidMain/kotlin/com/linroid/ketch/torrent/Sha1.jvmAndAndroid.kt @@ -0,0 +1,13 @@ +package com.linroid.ketch.torrent + +import java.security.MessageDigest + +/** + * SHA-1 digest using `java.security.MessageDigest`. + * + * Called by the leaf `actual fun sha1Digest` in `jvmMain` and + * `androidMain`. + */ +internal fun jvmSha1Digest(data: ByteArray): ByteArray { + return MessageDigest.getInstance("SHA-1").digest(data) +} diff --git a/library/torrent/src/jvmMain/kotlin/com/linroid/ketch/torrent/NativeLibraryLoader.kt b/library/torrent/src/jvmMain/kotlin/com/linroid/ketch/torrent/NativeLibraryLoader.kt new file mode 100644 index 00000000..1e504e06 --- /dev/null +++ b/library/torrent/src/jvmMain/kotlin/com/linroid/ketch/torrent/NativeLibraryLoader.kt @@ -0,0 +1,100 @@ +package com.linroid.ketch.torrent + +import java.io.File +import java.io.InputStream +import java.nio.file.Files + +/** + * Extracts the libtorrent4j native library from the classpath JAR + * to a temp directory and sets the `libtorrent4j.jni.path` system + * property so the SWIG static initializer uses `System.load()`. + * + * This is only needed on Desktop JVM — Android extracts native + * libraries from the APK automatically. + */ +internal object NativeLibraryLoader { + + @Volatile + private var loaded = false + + /** + * Ensures the native library is extracted and the system property + * is set. Safe to call multiple times — subsequent calls are no-ops. + */ + @Synchronized + fun ensureLoaded() { + if (loaded) return + // Skip if already configured externally + val existing = System.getProperty("libtorrent4j.jni.path", "") + if (existing.isNotEmpty()) { + loaded = true + return + } + val resourcePath = resolveResourcePath() + val libFile = extractToTempDir(resourcePath) + System.setProperty("libtorrent4j.jni.path", libFile.absolutePath) + loaded = true + } + + private fun resolveResourcePath(): String { + val os = System.getProperty("os.name", "") + .lowercase() + val arch = System.getProperty("os.arch", "") + .lowercase() + + val (libDir, libName) = when { + "mac" in os || "darwin" in os -> { + val dir = when (arch) { + "aarch64", "arm64" -> "arm64" + else -> "x86_64" + } + dir to "libtorrent4j.dylib" + } + "linux" in os -> { + val dir = when (arch) { + "aarch64", "arm64" -> "arm" + else -> "x86_64" + } + dir to "libtorrent4j.so" + } + "windows" in os || "win" in os -> { + "x86_64" to "libtorrent4j.dll" + } + else -> throw UnsupportedOperationException( + "Unsupported OS for libtorrent4j: $os" + ) + } + + return "lib/$libDir/$libName" + } + + private fun extractToTempDir(resourcePath: String): File { + val stream: InputStream = Thread.currentThread() + .contextClassLoader + .getResourceAsStream(resourcePath) + ?: NativeLibraryLoader::class.java.classLoader + ?.getResourceAsStream(resourcePath) + ?: throw UnsatisfiedLinkError( + "Native library not found on classpath: $resourcePath" + ) + + val tempDir = Files.createTempDirectory("ketch-torrent-native") + .toFile() + tempDir.deleteOnExit() + + val fileName = resourcePath.substringAfterLast('/') + val tempFile = File(tempDir, fileName) + tempFile.deleteOnExit() + + stream.use { input -> + tempFile.outputStream().use { output -> + input.copyTo(output) + } + } + + if (!tempFile.canExecute()) { + tempFile.setExecutable(true) + } + return tempFile + } +} diff --git a/library/torrent/src/jvmMain/kotlin/com/linroid/ketch/torrent/PlatformActuals.jvm.kt b/library/torrent/src/jvmMain/kotlin/com/linroid/ketch/torrent/PlatformActuals.jvm.kt new file mode 100644 index 00000000..0f09264f --- /dev/null +++ b/library/torrent/src/jvmMain/kotlin/com/linroid/ketch/torrent/PlatformActuals.jvm.kt @@ -0,0 +1,17 @@ +package com.linroid.ketch.torrent + +internal actual fun createTorrentEngine( + config: TorrentConfig, +): TorrentEngine { + NativeLibraryLoader.ensureLoaded() + return createLibtorrent4jEngine(config) +} + +internal actual fun encodeBase64(data: ByteArray): String = + jvmEncodeBase64(data) + +internal actual fun decodeBase64(data: String): ByteArray = + jvmDecodeBase64(data) + +internal actual fun sha1Digest(data: ByteArray): ByteArray = + jvmSha1Digest(data) diff --git a/settings.gradle.kts b/settings.gradle.kts index e3ab9f90..dfec9904 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -43,6 +43,7 @@ include(":library:ktor") include(":library:kermit") include(":library:sqlite") include(":library:ftp") +include(":library:torrent") include(":library:server") // AI modules