diff --git a/README.md b/README.md index 297adcd..05db0b7 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,15 @@ A lightweight Kotlin Multiplatform download manager for Android and iOS. Uses pl ## Features -- Simple, unified API across platforms +- Kotlin DSL for easy configuration - Progress tracking with callbacks -- Error handling with detailed information -- Cancel downloads by ID -- MIME type detection -- Platform-native implementations (Android `DownloadManager`, iOS `NSURLSession`) +- State management (pending, downloading, paused, completed, failed, cancelled) +- Authentication support (Bearer token, Basic auth) +- Custom headers +- Network type restrictions (WiFi only) +- Platform-native implementations: + - Android: `DownloadManager` (system-managed, survives app kill, shows notifications) + - iOS: `NSURLSession` (background download support) ## Installation @@ -37,42 +40,118 @@ dependencyResolutionManagement { kotlin { sourceSets { commonMain.dependencies { - implementation("dev.onexeor.kdownloader:shared:0.1.0") + implementation("dev.onexeor.kdownloader:shared:0.2.0") } } } ``` +### Android Setup + +Initialize KDownloader in your Application class: + +```kotlin +class MyApp : Application() { + override fun onCreate() { + super.onCreate() + KDownloader.init(this) + } +} +``` + ## Usage ### Basic Download ```kotlin -val downloader = KDownloader(context) // Android requires Context - -val downloadId = downloader.downloadFile( - url = "https://example.com/file.pdf", - fileName = "document.pdf", - progressListener = { uri, status -> - println("Download progress: $uri, status: $status") - }, - errorListener = { error -> - println("Error: ${error.description} (${error.statusCode})") +val downloader = KDownloader() + +downloader.download("https://example.com/file.pdf") { + fileName = "document.pdf" + + onProgress { progress -> + println("${progress.percentage}%") } -) + + onComplete { filePath -> + println("Downloaded to: $filePath") + } + + onError { error -> + println("Failed: ${error.message}") + } +} ``` -### Cancel Download +### With Authentication ```kotlin -downloader.cancelDownloadById(downloadId) +downloader.download("https://api.example.com/private/file.zip") { + fileName = "data.zip" + + auth { + bearer("your-access-token") + } + + onComplete { println("Done: $it") } +} ``` -### Get Download Info +### With Custom Headers ```kotlin -val mimeType = downloader.getMimeTypeById(downloadId) -val url = downloader.getUrlById(downloadId) +downloader.download("https://example.com/file.pdf") { + headers { + "X-Custom-Header" to "value" + "Accept" to "application/octet-stream" + } +} +``` + +### WiFi Only Download + +```kotlin +downloader.download("https://example.com/large-file.zip") { + wifiOnly() + + onStateChange { state -> + when (state) { + is DownloadState.Paused -> println("Waiting: ${state.reason}") + is DownloadState.Downloading -> println("Progress: ${state.progress.percentage}%") + is DownloadState.Completed -> println("Done!") + else -> {} + } + } +} +``` + +### Task Control + +```kotlin +val task = downloader.download("https://example.com/file.zip") + +// Add listeners after creation (fluent API) +task.onProgress { println("${it.percentage}%") } + .onComplete { println("Done: $it") } + .onError { println("Error: ${it.message}") } + +// Check current state +println("State: ${task.currentState}") +println("Progress: ${task.currentProgress.percentage}%") + +// Cancel if needed +task.cancel() +``` + +### Configuration + +```kotlin +val downloader = KDownloader( + KDownloaderConfig( + defaultDirectory = "MyApp/Downloads", + defaultNetworkType = NetworkType.ANY + ) +) ``` ## API Reference @@ -81,20 +160,40 @@ val url = downloader.getUrlById(downloadId) | Method | Description | |--------|-------------| -| `downloadFile(url, fileName?, progressListener?, errorListener?)` | Start a download, returns download ID | -| `cancelDownloadById(downloadId)` | Cancel an active download | -| `getMimeTypeById(downloadId)` | Get MIME type of downloaded file | -| `getUrlById(downloadId)` | Get original URL of download | - -### DownloadError +| `download(url, builder)` | Start a download with DSL configuration | +| `download(request)` | Start a download with pre-built request | +| `getTask(id)` | Get an existing download task by ID | +| `cancelAll()` | Cancel all active downloads | + +### DownloadState + +| State | Description | +|-------|-------------| +| `Pending` | Download is queued | +| `Downloading(progress)` | Download is in progress | +| `Paused(reason)` | Download is paused | +| `Completed(filePath)` | Download completed successfully | +| `Failed(error)` | Download failed with an error | +| `Cancelled` | Download was cancelled | + +### DownloadError Types + +| Type | Description | +|------|-------------| +| `Network` | Connection failed, timeout, etc. | +| `Http` | HTTP error response (4xx, 5xx) | +| `Storage` | Disk full, permission denied, etc. | +| `InvalidUrl` | Malformed URL | +| `Cancelled` | User cancelled the download | +| `Unknown` | Unexpected error | + +### Auth ```kotlin -data class DownloadError( - val url: String, // Original download URL - val status: Int, // Download manager status code - val description: String,// Human-readable error description - val statusCode: Int // HTTP status code -) +auth { + bearer("token") // Bearer token authentication + basic("user", "password") // Basic authentication +} ``` ## Platform Requirements @@ -102,14 +201,16 @@ data class DownloadError( | Platform | Minimum Version | |----------|-----------------| | Android | API 24 (7.0) | -| iOS | Supported via KMP | +| iOS | 13.0 | ## Roadmap -- [ ] Authentication support (Basic, Token) -- [ ] Cookie handling -- [ ] Custom headers +- [x] Authentication support (Bearer, Basic) +- [x] Custom headers +- [x] Kotlin DSL API - [ ] Download queue management +- [ ] Resume/pause support +- [ ] Download speed tracking ## License diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 13ff118..64e3fb5 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } group = "dev.onexeor.kdownloader" -version = "0.1.0" +version = "0.2.0" kotlin { androidTarget { diff --git a/shared/src/androidMain/kotlin/dev/onexeor/kdownloader/KDownloader.kt b/shared/src/androidMain/kotlin/dev/onexeor/kdownloader/KDownloader.kt index cca1f77..7cc028a 100644 --- a/shared/src/androidMain/kotlin/dev/onexeor/kdownloader/KDownloader.kt +++ b/shared/src/androidMain/kotlin/dev/onexeor/kdownloader/KDownloader.kt @@ -1,231 +1,171 @@ package dev.onexeor.kdownloader +import android.annotation.SuppressLint import android.app.DownloadManager import android.content.Context -import android.database.Cursor import android.net.Uri -import android.os.Build -import android.os.Environment import android.os.Handler import android.os.HandlerThread import android.os.Looper -import android.os.ParcelFileDescriptor import android.util.Base64 -import android.util.Log import android.webkit.MimeTypeMap -import androidx.core.database.getIntOrNull -import androidx.core.database.getStringOrNull -import dev.onexeor.kdownloader.auth.Auth -import dev.onexeor.kdownloader.extension.getFilePath -import dev.onexeor.kdownloader.extension.getFilePath29Api -import dev.onexeor.kdownloader.extension.isExternalStorageWritable -import kotlin.properties.Delegates - -actual class KDownloader { - - /** - * Needs to be set at the Android side, preferably in the `onCreate` method of the Application class - */ - var context: Context by Delegates.notNull() - private val downloadService by lazy { context.getSystemService(DownloadManager::class.java) } - private var handler: Handler - private var statusHandler: Handler = Handler(Looper.getMainLooper()) - - init { - HandlerThread(this::class.java.canonicalName).apply { - start() - handler = Handler(looper) - } +import dev.onexeor.kdownloader.internal.AndroidDownloadTask +import dev.onexeor.kdownloader.internal.getDownloadDirectory +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * Android implementation of KDownloader using system DownloadManager. + * + * Note: Context must be initialized before use via [KDownloader.init]. + */ +actual class KDownloader actual constructor( + private val config: KDownloaderConfig +) { + private val downloadManager: DownloadManager by lazy { + appContext.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager } - actual fun cancelDownloadById(downloadId: Long) { - downloadService.remove(downloadId) - } + private val tasks = ConcurrentHashMap() + private val workerThread = HandlerThread("KDownloader-Worker").apply { start() } + private val workerHandler = Handler(workerThread.looper) + private val mainHandler = Handler(Looper.getMainLooper()) - actual fun getMimeTypeById(downloadId: Long): String? { - return downloadService.getMimeTypeForDownloadedFile(downloadId) + actual fun download(url: String, builder: DownloadRequestBuilder.() -> Unit): DownloadTask { + val request = DownloadRequestBuilder(url).apply(builder).build() + return download(request) } - actual fun getUrlById(downloadId: Long): String? { - return downloadService.getUriForDownloadedFile(downloadId)?.toString() - } + actual fun download(request: DownloadRequest): DownloadTask { + val taskId = UUID.randomUUID().toString() - fun openDownloadById(downloadId: Long): ParcelFileDescriptor? { - return downloadService.openDownloadedFile(downloadId) - } + // Determine file name + val fileName = request.fileName ?: extractFileNameFromUrl(request.url) - actual fun downloadFile( - url: String, - fileName: String?, - progressListener: ((String, Int) -> Unit)?, - errorListener: ((DownloadError) -> Unit)? - ): Long { - val defaultAuth = Auth.BasicAuth("", "") - - val extOut = when { - fileName != null -> fileName.substringAfterLast(".", "") - else -> ".txt" - } - val mimeTypeOut = when { - else -> MimeTypeMap.getSingleton().getMimeTypeFromExtension(extOut) ?: DEFAULT_MIME_TYPE - } + // Determine directory + val directory = request.directory ?: config.defaultDirectory - return startDownloadManager( - url = url, - fileName = fileName ?: "${System.currentTimeMillis()}.txt", - auth = defaultAuth, - mimeType = mimeTypeOut, - progressListener = progressListener, - errorListener = errorListener - ) - } + // Get download URI + val downloadUri = getDownloadDirectory(appContext, directory, fileName) + ?: throw IllegalStateException("Could not create download directory") - private fun startDownloadManager( - url: String, - downloadDialogTitle: String? = null, - downloadDialogDescription: String? = null, - fileName: String, - normalizedCookies: String? = null, - mimeType: String, - auth: Auth = Auth.BasicAuth("", ""), - downloadDeclineListener: (() -> Unit)? = null, - progressListener: ((String, Int) -> Unit)? = null, - errorListener: ((DownloadError) -> Unit)? = null - ): Long { - val headerCredentials = when (auth) { - is Auth.BasicAuth -> "Basic " + Base64.encodeToString( - "${auth.login}:${auth.password}".toByteArray(), - Base64.NO_WRAP - ) - - is Auth.TokenAuth -> auth.token - } + // Build DownloadManager request + val dmRequest = DownloadManager.Request(Uri.parse(request.url)).apply { + setTitle(fileName) + setDescription("Downloading $fileName") + setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + setDestinationUri(downloadUri) - if (!Environment().isExternalStorageWritable()) { - downloadDeclineListener?.invoke() - return -1 + // Set network type + val networkType = request.networkType ?: config.defaultNetworkType + when (networkType) { + NetworkType.ANY -> setAllowedNetworkTypes( + DownloadManager.Request.NETWORK_WIFI or DownloadManager.Request.NETWORK_MOBILE + ) + NetworkType.WIFI_ONLY -> setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI) + } + + // Set MIME type + val mimeType = getMimeType(fileName) + setMimeType(mimeType) + + // Add headers + request.headers.forEach { (key, value) -> + addRequestHeader(key, value) + } + + // Add auth header + request.auth?.let { auth -> + val authHeader = when (auth) { + is Auth.Bearer -> "Bearer ${auth.token}" + is Auth.Basic -> { + val credentials = "${auth.username}:${auth.password}" + "Basic ${Base64.encodeToString(credentials.toByteArray(), Base64.NO_WRAP)}" + } + } + addRequestHeader("Authorization", authHeader) + } } - val newFileName = fileName.replace( - Regex("[^a-zA-Z0-9À-ÿ.\\s]+"), - " " + // Enqueue download + val downloadId = downloadManager.enqueue(dmRequest) + + // Create task + val task = AndroidDownloadTask( + id = taskId, + downloadId = downloadId, + request = request, + filePath = downloadUri.path ?: "", + downloadManager = downloadManager, + workerHandler = workerHandler, + mainHandler = mainHandler, + initialListeners = request.listeners ) - val downloadUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - getFilePath29Api(context, newFileName) - } else { - getFilePath(newFileName) - } - val request = DownloadManager.Request(Uri.parse(url)) - .setTitle(downloadDialogTitle ?: context.getString(R.string.app_name)) - .setAllowedNetworkTypes( - DownloadManager.Request.NETWORK_WIFI or DownloadManager.Request.NETWORK_MOBILE - ) - .setMimeType(mimeType) - .addRequestHeader("Authorization", headerCredentials) - .addRequestHeader("Cookie", normalizedCookies) - .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) - .setDescription( - downloadDialogDescription ?: String.format( - context.getString(R.string.download_dialog_description), - fileName - ) - ) - .setDestinationUri(downloadUri) + tasks[taskId] = task - val dwnID = downloadService.enqueue(request) + // Start progress monitoring + task.startMonitoring() - downloadUri?.let { - this.getDownloadStatus(dwnID, it, progressListener, errorListener) - } - return dwnID + return task } - private fun getDownloadStatus( - dwnID: Long, - downloadUri: Uri, - progressListener: ((String, Int) -> Unit)?, - errorListener: ((DownloadError) -> Unit)? = null - ) { - handler.removeCallbacksAndMessages(null) - handler.post { - try { - var downloading = true - while (downloading) { - val query = DownloadManager.Query().setFilterById(dwnID) - val cursor: Cursor = downloadService.query(query) - if (cursor.moveToFirst()) { - val columnDescriptionIdx = cursor.getColumnIndex( - DownloadManager.COLUMN_DESCRIPTION - ) - val columnReasonIdx = cursor.getColumnIndex(DownloadManager.COLUMN_REASON) - val columnStatusIdx = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS) - val dwnStatus = cursor.getIntOrNull(columnStatusIdx) - val dwnDescription = cursor.getStringOrNull(columnDescriptionIdx) - val dwnHttpStatusCode = cursor.getIntOrNull(columnReasonIdx) - - statusHandler.post { - when (dwnStatus) { - DownloadManager.STATUS_FAILED -> { - errorListener?.invoke( - DownloadError( - url = downloadUri.toString(), - status = dwnStatus, - description = dwnDescription.orEmpty(), - statusCode = dwnHttpStatusCode ?: -1 - ) - ) - progressListener?.invoke(downloadUri.toString(), dwnStatus) - } - - DownloadManager.STATUS_PAUSED, - DownloadManager.STATUS_SUCCESSFUL, - DownloadManager.STATUS_PENDING - -> progressListener?.invoke(downloadUri.toString(), dwnStatus) - } - } - if (dwnStatus == DownloadManager.STATUS_SUCCESSFUL || - dwnStatus == DownloadManager.STATUS_FAILED - ) { - downloading = false - } - } + actual fun getTask(id: String): DownloadTask? = tasks[id] - Log.d( - KDownloader::class.simpleName, - "Download status: " + statusMessage(cursor) - ) - cursor.close() - } - } catch (e: Exception) { - statusHandler.post { - progressListener?.invoke( - downloadUri.toString(), - DownloadManager.STATUS_FAILED - ) - } - e.printStackTrace() - Log.e( - KDownloader::class.simpleName, - "Error while downloading file: " + e.message - ) + actual fun cancelAll() { + tasks.values.forEach { it.cancel() } + tasks.clear() + } + + private fun extractFileNameFromUrl(url: String): String { + return try { + val path = Uri.parse(url).lastPathSegment + if (!path.isNullOrBlank() && path.contains(".")) { + path + } else { + "${System.currentTimeMillis()}.bin" } + } catch (e: Exception) { + "${System.currentTimeMillis()}.bin" } } - companion object { - private const val DEFAULT_MIME_TYPE = "application/*" + private fun getMimeType(fileName: String): String { + val extension = fileName.substringAfterLast(".", "") + return if (extension.isNotEmpty()) { + MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: DEFAULT_MIME_TYPE + } else { + DEFAULT_MIME_TYPE + } } - private fun statusMessage(c: Cursor): String { - val columnStatusIdx = c.getColumnIndex(DownloadManager.COLUMN_STATUS) - return when (c.getInt(columnStatusIdx)) { - DownloadManager.STATUS_FAILED -> "Download failed!" - DownloadManager.STATUS_PAUSED -> "Download paused!" - DownloadManager.STATUS_PENDING -> "Download pending!" - DownloadManager.STATUS_RUNNING -> "Download in progress!" - DownloadManager.STATUS_SUCCESSFUL -> "Download complete!" - else -> "Download is nowhere in sight" + companion object { + private const val DEFAULT_MIME_TYPE = "application/octet-stream" + + @SuppressLint("StaticFieldLeak") + private lateinit var appContext: Context + + /** + * Initialize KDownloader with application context. + * Call this in Application.onCreate(). + * + * ```kotlin + * class MyApp : Application() { + * override fun onCreate() { + * super.onCreate() + * KDownloader.init(this) + * } + * } + * ``` + */ + fun init(context: Context) { + appContext = context.applicationContext } + + /** + * Check if KDownloader has been initialized. + */ + val isInitialized: Boolean + get() = ::appContext.isInitialized } } diff --git a/shared/src/androidMain/kotlin/dev/onexeor/kdownloader/internal/AndroidDownloadTask.kt b/shared/src/androidMain/kotlin/dev/onexeor/kdownloader/internal/AndroidDownloadTask.kt new file mode 100644 index 0000000..79243ac --- /dev/null +++ b/shared/src/androidMain/kotlin/dev/onexeor/kdownloader/internal/AndroidDownloadTask.kt @@ -0,0 +1,247 @@ +package dev.onexeor.kdownloader.internal + +import android.app.DownloadManager +import android.database.Cursor +import android.os.Handler +import dev.onexeor.kdownloader.DownloadError +import dev.onexeor.kdownloader.DownloadListeners +import dev.onexeor.kdownloader.DownloadProgress +import dev.onexeor.kdownloader.DownloadRequest +import dev.onexeor.kdownloader.DownloadState +import dev.onexeor.kdownloader.DownloadTask + +/** + * Android implementation of DownloadTask using DownloadManager. + */ +internal class AndroidDownloadTask( + override val id: String, + val downloadId: Long, + override val request: DownloadRequest, + val filePath: String, + private val downloadManager: DownloadManager, + private val workerHandler: Handler, + private val mainHandler: Handler, + private val initialListeners: DownloadListeners +) : DownloadTask { + + @Volatile + private var _currentState: DownloadState = DownloadState.Pending + override val currentState: DownloadState get() = _currentState + + @Volatile + private var _currentProgress: DownloadProgress = DownloadProgress.ZERO + override val currentProgress: DownloadProgress get() = _currentProgress + + @Volatile + private var isMonitoring = false + + @Volatile + private var isCancelled = false + + private val progressListeners = mutableListOf<(DownloadProgress) -> Unit>() + private val stateListeners = mutableListOf<(DownloadState) -> Unit>() + private val completeListeners = mutableListOf<(String) -> Unit>() + private val errorListeners = mutableListOf<(DownloadError) -> Unit>() + + init { + // Add initial listeners from request + initialListeners.onProgress?.let { progressListeners.add(it) } + initialListeners.onStateChange?.let { stateListeners.add(it) } + initialListeners.onComplete?.let { completeListeners.add(it) } + initialListeners.onError?.let { errorListeners.add(it) } + } + + override fun cancel() { + isCancelled = true + isMonitoring = false + downloadManager.remove(downloadId) + updateState(DownloadState.Cancelled) + notifyError(DownloadError.Cancelled) + } + + override fun onProgress(listener: (DownloadProgress) -> Unit): DownloadTask { + progressListeners.add(listener) + return this + } + + override fun onStateChange(listener: (DownloadState) -> Unit): DownloadTask { + stateListeners.add(listener) + return this + } + + override fun onComplete(listener: (String) -> Unit): DownloadTask { + completeListeners.add(listener) + return this + } + + override fun onError(listener: (DownloadError) -> Unit): DownloadTask { + errorListeners.add(listener) + return this + } + + fun startMonitoring() { + if (isMonitoring) return + isMonitoring = true + + workerHandler.post { pollProgress() } + } + + private fun pollProgress() { + if (!isMonitoring || isCancelled) return + + val query = DownloadManager.Query().setFilterById(downloadId) + var cursor: Cursor? = null + + try { + cursor = downloadManager.query(query) + + if (cursor == null || !cursor.moveToFirst()) { + // Download not found, might have been removed + if (!isCancelled) { + notifyError(DownloadError.Unknown("Download not found")) + } + return + } + + val statusIndex = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS) + val bytesDownloadedIndex = cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR) + val totalBytesIndex = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES) + val reasonIndex = cursor.getColumnIndex(DownloadManager.COLUMN_REASON) + + val status = cursor.getInt(statusIndex) + val bytesDownloaded = cursor.getLong(bytesDownloadedIndex) + val totalBytes = cursor.getLong(totalBytesIndex) + + // Update progress + val percentage = if (totalBytes > 0) { + ((bytesDownloaded * 100) / totalBytes).toInt() + } else { + -1 + } + + val progress = DownloadProgress( + bytesDownloaded = bytesDownloaded, + totalBytes = totalBytes, + percentage = percentage + ) + + if (progress != _currentProgress) { + _currentProgress = progress + notifyProgress(progress) + } + + when (status) { + DownloadManager.STATUS_PENDING -> { + updateState(DownloadState.Pending) + } + + DownloadManager.STATUS_RUNNING -> { + updateState(DownloadState.Downloading(progress)) + } + + DownloadManager.STATUS_PAUSED -> { + val reason = cursor.getInt(reasonIndex) + val message = getPausedReason(reason) + updateState(DownloadState.Paused(message)) + } + + DownloadManager.STATUS_SUCCESSFUL -> { + isMonitoring = false + updateState(DownloadState.Completed(filePath)) + notifyComplete(filePath) + return + } + + DownloadManager.STATUS_FAILED -> { + isMonitoring = false + val reason = cursor.getInt(reasonIndex) + val error = getDownloadError(reason) + updateState(DownloadState.Failed(error)) + notifyError(error) + return + } + } + + // Continue polling + if (isMonitoring && !isCancelled) { + workerHandler.postDelayed({ pollProgress() }, POLL_INTERVAL_MS) + } + + } catch (e: Exception) { + if (!isCancelled) { + val error = DownloadError.Unknown("Error monitoring download: ${e.message}", e) + updateState(DownloadState.Failed(error)) + notifyError(error) + } + } finally { + cursor?.close() + } + } + + private fun updateState(newState: DownloadState) { + if (_currentState != newState) { + _currentState = newState + notifyState(newState) + } + } + + private fun notifyProgress(progress: DownloadProgress) { + mainHandler.post { + progressListeners.forEach { it(progress) } + } + } + + private fun notifyState(state: DownloadState) { + mainHandler.post { + stateListeners.forEach { it(state) } + } + } + + private fun notifyComplete(path: String) { + mainHandler.post { + completeListeners.forEach { it(path) } + } + } + + private fun notifyError(error: DownloadError) { + mainHandler.post { + errorListeners.forEach { it(error) } + } + } + + private fun getPausedReason(reason: Int): String { + return when (reason) { + DownloadManager.PAUSED_QUEUED_FOR_WIFI -> "Waiting for WiFi" + DownloadManager.PAUSED_WAITING_FOR_NETWORK -> "Waiting for network" + DownloadManager.PAUSED_WAITING_TO_RETRY -> "Waiting to retry" + DownloadManager.PAUSED_UNKNOWN -> "Paused" + else -> "Paused" + } + } + + private fun getDownloadError(reason: Int): DownloadError { + return when (reason) { + DownloadManager.ERROR_CANNOT_RESUME -> DownloadError.Network("Cannot resume download") + DownloadManager.ERROR_DEVICE_NOT_FOUND -> DownloadError.Storage("Storage device not found") + DownloadManager.ERROR_FILE_ALREADY_EXISTS -> DownloadError.Storage("File already exists") + DownloadManager.ERROR_FILE_ERROR -> DownloadError.Storage("File error") + DownloadManager.ERROR_HTTP_DATA_ERROR -> DownloadError.Network("HTTP data error") + DownloadManager.ERROR_INSUFFICIENT_SPACE -> DownloadError.Storage("Insufficient storage space") + DownloadManager.ERROR_TOO_MANY_REDIRECTS -> DownloadError.Network("Too many redirects") + DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> DownloadError.Http(0, "Unhandled HTTP error") + DownloadManager.ERROR_UNKNOWN -> DownloadError.Unknown("Unknown download error") + else -> { + // HTTP error codes are returned directly for 4xx and 5xx + if (reason in 400..599) { + DownloadError.Http(reason, "HTTP error $reason") + } else { + DownloadError.Unknown("Unknown error: $reason") + } + } + } + } + + companion object { + private const val POLL_INTERVAL_MS = 500L + } +} diff --git a/shared/src/androidMain/kotlin/dev/onexeor/kdownloader/internal/DirectoryUtils.kt b/shared/src/androidMain/kotlin/dev/onexeor/kdownloader/internal/DirectoryUtils.kt new file mode 100644 index 0000000..8640020 --- /dev/null +++ b/shared/src/androidMain/kotlin/dev/onexeor/kdownloader/internal/DirectoryUtils.kt @@ -0,0 +1,63 @@ +package dev.onexeor.kdownloader.internal + +import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.Environment +import androidx.core.content.ContextCompat +import java.io.File + +/** + * Get download directory URI based on the specified directory and file name. + * Handles scoped storage for Android 10+. + */ +internal fun getDownloadDirectory( + context: Context, + directory: String?, + fileName: String +): Uri? { + val targetDir = directory ?: "Downloads" + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Android 10+ with scoped storage + getDownloadPathScoped(context, targetDir, fileName) + } else { + // Legacy storage + getDownloadPathLegacy(targetDir, fileName) + } +} + +private fun getDownloadPathScoped( + context: Context, + directory: String, + fileName: String +): Uri? { + val externalStorageVolumes = ContextCompat.getExternalFilesDirs(context, null) + if (externalStorageVolumes.isEmpty()) return null + + val primaryExternalStorage = externalStorageVolumes[0] ?: return null + + val folder = File(primaryExternalStorage, directory) + if (!folder.exists()) { + folder.mkdirs() + } + + val file = File(folder, fileName) + return Uri.fromFile(file) +} + +@Suppress("DEPRECATION") +private fun getDownloadPathLegacy( + directory: String, + fileName: String +): Uri? { + val externalStorage = Environment.getExternalStorageDirectory() + + val folder = File(externalStorage, directory) + if (!folder.exists()) { + folder.mkdirs() + } + + val file = File(folder, fileName) + return Uri.fromFile(file) +} diff --git a/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/Auth.kt b/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/Auth.kt new file mode 100644 index 0000000..df1d5bf --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/Auth.kt @@ -0,0 +1,18 @@ +package dev.onexeor.kdownloader + +/** + * Authentication configuration for downloads. + */ +sealed class Auth { + /** + * Bearer token authentication. + * Adds `Authorization: Bearer ` header. + */ + data class Bearer(val token: String) : Auth() + + /** + * Basic authentication with username and password. + * Adds `Authorization: Basic ` header. + */ + data class Basic(val username: String, val password: String) : Auth() +} diff --git a/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/DownloadError.kt b/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/DownloadError.kt index 73f5b9c..f9f2c3f 100644 --- a/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/DownloadError.kt +++ b/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/DownloadError.kt @@ -1,8 +1,58 @@ package dev.onexeor.kdownloader -data class DownloadError( - val url: String, - val status: Int, - val description: String, - val statusCode: Int -) +/** + * Represents errors that can occur during download. + * + * @property message Human-readable error description + * @property cause Underlying exception, if any + */ +sealed class DownloadError( + open val message: String, + open val cause: Throwable? = null +) { + /** + * Network-related error (connection failed, timeout, etc.) + */ + data class Network( + override val message: String, + override val cause: Throwable? = null + ) : DownloadError(message, cause) + + /** + * HTTP error response from server. + * + * @property statusCode HTTP status code (4xx, 5xx) + */ + data class Http( + val statusCode: Int, + override val message: String + ) : DownloadError(message) + + /** + * Storage-related error (disk full, permission denied, etc.) + */ + data class Storage( + override val message: String, + override val cause: Throwable? = null + ) : DownloadError(message, cause) + + /** + * Invalid or malformed URL. + */ + data class InvalidUrl( + override val message: String + ) : DownloadError(message) + + /** + * Download was cancelled. + */ + data object Cancelled : DownloadError("Download cancelled") + + /** + * Unknown or unexpected error. + */ + data class Unknown( + override val message: String, + override val cause: Throwable? = null + ) : DownloadError(message, cause) +} diff --git a/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/DownloadProgress.kt b/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/DownloadProgress.kt new file mode 100644 index 0000000..c78da1c --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/DownloadProgress.kt @@ -0,0 +1,35 @@ +package dev.onexeor.kdownloader + +/** + * Represents the progress of a download. + * + * @property bytesDownloaded Number of bytes downloaded so far + * @property totalBytes Total size in bytes, or -1 if unknown + * @property percentage Download percentage (0-100), or -1 if unknown + */ +data class DownloadProgress( + val bytesDownloaded: Long, + val totalBytes: Long, + val percentage: Int +) { + companion object { + /** + * Initial progress state before download starts. + */ + val ZERO = DownloadProgress( + bytesDownloaded = 0, + totalBytes = -1, + percentage = -1 + ) + } + + /** + * Whether the total size is known. + */ + val isSizeKnown: Boolean get() = totalBytes > 0 + + /** + * Whether the download has started (bytes received). + */ + val hasStarted: Boolean get() = bytesDownloaded > 0 +} diff --git a/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/DownloadRequest.kt b/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/DownloadRequest.kt new file mode 100644 index 0000000..8f2587a --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/DownloadRequest.kt @@ -0,0 +1,183 @@ +package dev.onexeor.kdownloader + +/** + * Immutable download request configuration. + */ +class DownloadRequest internal constructor( + val url: String, + val fileName: String?, + val directory: String?, + val headers: Map, + val auth: Auth?, + val networkType: NetworkType?, + val overwrite: Boolean, + internal val listeners: DownloadListeners +) + +/** + * DSL builder for creating download requests. + * + * Example: + * ```kotlin + * downloader.download("https://example.com/file.zip") { + * fileName = "archive.zip" + * directory = "/custom/path" + * + * headers { + * "Authorization" to "Bearer token" + * "Accept" to "application/octet-stream" + * } + * + * auth { + * bearer("token") + * } + * + * wifiOnly() + * overwriteIfExists() + * + * onProgress { progress -> + * println("${progress.percentage}%") + * } + * + * onComplete { filePath -> + * println("Downloaded to: $filePath") + * } + * + * onError { error -> + * println("Failed: ${error.message}") + * } + * } + * ``` + */ +class DownloadRequestBuilder(val url: String) { + /** + * File name for the downloaded file. + * If null, will be derived from URL or use a generated name. + */ + var fileName: String? = null + + /** + * Directory to save the file. + * If null, uses the downloader's default directory. + */ + var directory: String? = null + + /** + * Whether to overwrite existing file with same name. + */ + var overwrite: Boolean = false + + /** + * Network type restriction for this download. + * If null, uses the downloader's default. + */ + var networkType: NetworkType? = null + + private val headersMap = mutableMapOf() + private var authValue: Auth? = null + private val listenersBuilder = DownloadListeners() + + /** + * Configure request headers. + */ + fun headers(block: HeadersBuilder.() -> Unit) { + HeadersBuilder(headersMap).apply(block) + } + + /** + * Configure authentication. + */ + fun auth(block: AuthBuilder.() -> Unit) { + authValue = AuthBuilder().apply(block).build() + } + + /** + * Restrict download to WiFi only. + */ + fun wifiOnly() { + networkType = NetworkType.WIFI_ONLY + } + + /** + * Allow overwriting existing files. + */ + fun overwriteIfExists() { + overwrite = true + } + + /** + * Called when download progress updates. + */ + fun onProgress(listener: (DownloadProgress) -> Unit) { + listenersBuilder.onProgress = listener + } + + /** + * Called when download state changes. + */ + fun onStateChange(listener: (DownloadState) -> Unit) { + listenersBuilder.onStateChange = listener + } + + /** + * Called when download completes successfully. + * @param listener receives the file path + */ + fun onComplete(listener: (String) -> Unit) { + listenersBuilder.onComplete = listener + } + + /** + * Called when download fails. + */ + fun onError(listener: (DownloadError) -> Unit) { + listenersBuilder.onError = listener + } + + internal fun build(): DownloadRequest = DownloadRequest( + url = url, + fileName = fileName, + directory = directory, + headers = headersMap.toMap(), + auth = authValue, + networkType = networkType, + overwrite = overwrite, + listeners = listenersBuilder + ) +} + +/** + * DSL builder for HTTP headers. + */ +class HeadersBuilder(private val headers: MutableMap) { + infix fun String.to(value: String) { + headers[this] = value + } +} + +/** + * DSL builder for authentication. + */ +class AuthBuilder { + private var auth: Auth? = null + + fun bearer(token: String) { + auth = Auth.Bearer(token) + } + + fun basic(username: String, password: String) { + auth = Auth.Basic(username, password) + } + + internal fun build(): Auth? = auth +} + +/** + * Internal holder for download listeners. + */ +internal class DownloadListeners { + var onProgress: ((DownloadProgress) -> Unit)? = null + var onStateChange: ((DownloadState) -> Unit)? = null + var onComplete: ((String) -> Unit)? = null + var onError: ((DownloadError) -> Unit)? = null +} diff --git a/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/DownloadState.kt b/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/DownloadState.kt new file mode 100644 index 0000000..7c4f0e2 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/DownloadState.kt @@ -0,0 +1,37 @@ +package dev.onexeor.kdownloader + +/** + * Represents the current state of a download. + */ +sealed class DownloadState { + /** + * Download is queued but not yet started. + */ + data object Pending : DownloadState() + + /** + * Download is actively in progress. + */ + data class Downloading(val progress: DownloadProgress) : DownloadState() + + /** + * Download is paused. + */ + data class Paused(val reason: String) : DownloadState() + + /** + * Download completed successfully. + * @property filePath Path to the downloaded file + */ + data class Completed(val filePath: String) : DownloadState() + + /** + * Download failed with an error. + */ + data class Failed(val error: DownloadError) : DownloadState() + + /** + * Download was cancelled by the user. + */ + data object Cancelled : DownloadState() +} diff --git a/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/DownloadTask.kt b/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/DownloadTask.kt new file mode 100644 index 0000000..49d4c6e --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/DownloadTask.kt @@ -0,0 +1,77 @@ +package dev.onexeor.kdownloader + +/** + * Handle for a download operation. + * + * Provides methods to control and observe the download. + * + * Example: + * ```kotlin + * val task = downloader.download("https://example.com/file.zip") { + * fileName = "file.zip" + * } + * + * // Check state + * println("State: ${task.currentState}") + * println("Progress: ${task.currentProgress.percentage}%") + * + * // Add listeners after creation + * task.onProgress { println("${it.percentage}%") } + * .onComplete { println("Done: $it") } + * .onError { println("Error: ${it.message}") } + * + * // Control + * task.cancel() + * ``` + */ +interface DownloadTask { + /** + * Unique identifier for this download. + */ + val id: String + + /** + * The original request configuration. + */ + val request: DownloadRequest + + /** + * Current download state. + */ + val currentState: DownloadState + + /** + * Current download progress. + */ + val currentProgress: DownloadProgress + + /** + * Cancel this download. + */ + fun cancel() + + /** + * Add a progress listener. + * @return this task for chaining + */ + fun onProgress(listener: (DownloadProgress) -> Unit): DownloadTask + + /** + * Add a state change listener. + * @return this task for chaining + */ + fun onStateChange(listener: (DownloadState) -> Unit): DownloadTask + + /** + * Add a completion listener. + * @param listener receives the file path + * @return this task for chaining + */ + fun onComplete(listener: (String) -> Unit): DownloadTask + + /** + * Add an error listener. + * @return this task for chaining + */ + fun onError(listener: (DownloadError) -> Unit): DownloadTask +} diff --git a/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/KDownloader.kt b/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/KDownloader.kt index c452646..ea535fd 100644 --- a/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/KDownloader.kt +++ b/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/KDownloader.kt @@ -1,33 +1,51 @@ package dev.onexeor.kdownloader -expect class KDownloader { +/** + * Main entry point for downloading files. + * + * Platform-specific implementations use native download managers: + * - Android: DownloadManager (system-managed, survives app kill) + * - iOS: NSURLSession (background download support) + * + * Example: + * ```kotlin + * val downloader = KDownloader() + * + * downloader.download("https://example.com/file.pdf") { + * fileName = "document.pdf" + * onComplete { println("Downloaded: $it") } + * } + * ``` + */ +expect class KDownloader(config: KDownloaderConfig = KDownloaderConfig()) { + /** + * Start a download with DSL configuration. * + * @param url The URL to download from + * @param builder DSL block to configure the download request + * @return DownloadTask handle for tracking and controlling the download */ - fun cancelDownloadById(downloadId: Long) + fun download(url: String, builder: DownloadRequestBuilder.() -> Unit = {}): DownloadTask /** + * Start a download with a pre-built request. * + * @param request The download request configuration + * @return DownloadTask handle for tracking and controlling the download */ - fun getMimeTypeById(downloadId: Long): String? + fun download(request: DownloadRequest): DownloadTask /** + * Get an existing download task by ID. * + * @param id The task ID + * @return The task if found, null otherwise */ - fun getUrlById(downloadId: Long): String? + fun getTask(id: String): DownloadTask? /** - * - * @param url http or https url - * @param fileName if null will be [System.currentTimeMillis].txt - * @param progressListener can be nullable, consist of [Uri] to file and downloading status, see [android.app.DownloadManager] constants - * - * @return download id + * Cancel all active downloads. */ - fun downloadFile( - url: String, - fileName: String?, - progressListener: ((String, Int) -> Unit)?, - errorListener: ((DownloadError) -> Unit)? - ): Long + fun cancelAll() } diff --git a/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/KDownloaderConfig.kt b/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/KDownloaderConfig.kt new file mode 100644 index 0000000..b643b31 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/KDownloaderConfig.kt @@ -0,0 +1,32 @@ +package dev.onexeor.kdownloader + +/** + * Configuration for KDownloader instance. + * + * Example: + * ```kotlin + * val downloader = KDownloader( + * KDownloaderConfig( + * defaultDirectory = "/downloads", + * maxConcurrentDownloads = 2 + * ) + * ) + * ``` + */ +data class KDownloaderConfig( + /** + * Default directory for downloads. + * If null, uses platform default (Documents folder). + */ + val defaultDirectory: String? = null, + + /** + * Maximum number of concurrent downloads. + */ + val maxConcurrentDownloads: Int = 3, + + /** + * Default network type restriction. + */ + val defaultNetworkType: NetworkType = NetworkType.ANY +) diff --git a/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/NetworkType.kt b/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/NetworkType.kt new file mode 100644 index 0000000..18d6b24 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/NetworkType.kt @@ -0,0 +1,16 @@ +package dev.onexeor.kdownloader + +/** + * Network type restriction for downloads. + */ +enum class NetworkType { + /** + * Allow download on any network (WiFi or cellular). + */ + ANY, + + /** + * Only download when connected to WiFi. + */ + WIFI_ONLY +} diff --git a/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/auth/Auth.kt b/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/auth/Auth.kt deleted file mode 100644 index 7548086..0000000 --- a/shared/src/commonMain/kotlin/dev/onexeor/kdownloader/auth/Auth.kt +++ /dev/null @@ -1,16 +0,0 @@ -package dev.onexeor.kdownloader.auth - -/** - * - */ -sealed class Auth { - /** - * - */ - data class BasicAuth(val login: String, val password: String) : Auth() - - /** - * - */ - data class TokenAuth(val token: String) : Auth() -} diff --git a/shared/src/iosMain/kotlin/dev/onexeor/kdownloader/KDownloader.kt b/shared/src/iosMain/kotlin/dev/onexeor/kdownloader/KDownloader.kt index a497f07..725afe7 100644 --- a/shared/src/iosMain/kotlin/dev/onexeor/kdownloader/KDownloader.kt +++ b/shared/src/iosMain/kotlin/dev/onexeor/kdownloader/KDownloader.kt @@ -2,113 +2,66 @@ package dev.onexeor.kdownloader +import dev.onexeor.kdownloader.internal.IosDownloadTask import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.ObjCObjectVar -import platform.posix.time -import kotlinx.cinterop.alloc -import kotlinx.cinterop.memScoped -import kotlinx.cinterop.ptr -import platform.Foundation.NSDocumentDirectory -import platform.Foundation.NSError -import platform.Foundation.NSFileManager -import platform.Foundation.NSHTTPURLResponse -import platform.Foundation.NSSearchPathForDirectoriesInDomains -import platform.Foundation.NSURL -import platform.Foundation.NSURLResponse -import platform.Foundation.NSURLSession -import platform.Foundation.NSURLSessionDownloadTask -import platform.Foundation.NSUserDomainMask -import platform.Foundation.downloadTaskWithURL -import platform.Foundation.lastPathComponent +import platform.Foundation.NSUUID -actual class KDownloader { +/** + * iOS implementation of KDownloader using NSURLSession. + */ +actual class KDownloader actual constructor( + private val config: KDownloaderConfig +) { + private val tasks = mutableMapOf() - private val tasks = mutableMapOf() - - /** - * - */ - actual fun cancelDownloadById(downloadId: Long) { - tasks[downloadId]?.cancel() + actual fun download(url: String, builder: DownloadRequestBuilder.() -> Unit): DownloadTask { + val request = DownloadRequestBuilder(url).apply(builder).build() + return download(request) } - /** - * - */ - actual fun getMimeTypeById(downloadId: Long): String? { - return tasks[downloadId]?.originalRequest?.URL?.lastPathComponent - } + actual fun download(request: DownloadRequest): DownloadTask { + val taskId = NSUUID().UUIDString - /** - * - */ - actual fun getUrlById(downloadId: Long): String? { - return tasks[downloadId]?.originalRequest?.URL.toString() - } + // Determine file name + val fileName = request.fileName ?: extractFileNameFromUrl(request.url) - /** - * @param url http or https url - * @param fileName if null will be [System.currentTimeMillis].txt - * @param progressListener can be nullable, consist of [Uri] to file and downloading status, see [android.app.DownloadManager] constants - * - * @return download id - */ - actual fun downloadFile( - url: String, - fileName: String?, - progressListener: ((String, Int) -> Unit)?, - errorListener: ((DownloadError) -> Unit)? - ): Long { - fun complete(url: NSURL?, response: NSURLResponse?, error: NSError?) { - val httpResponse = response as? NSHTTPURLResponse - val httpStatusCode = httpResponse?.statusCode?.toInt() ?: -1 - if (error != null) { - errorListener?.invoke( - DownloadError(url.toString(), -1, "Error while downloading", httpStatusCode) - ) - return - } + // Determine directory + val directory = request.directory ?: config.defaultDirectory - if (url == null) { - errorListener?.invoke(DownloadError("NULL", -1, "URL is null", httpStatusCode)) - return - } + // Create task + val task = IosDownloadTask( + id = taskId, + request = request, + fileName = fileName, + directory = directory, + initialListeners = request.listeners + ) - val documentsDirectory = NSSearchPathForDirectoriesInDomains( - NSDocumentDirectory, - NSUserDomainMask, - true - ).first() - val fileManager = NSFileManager.defaultManager - val writablePath = "$documentsDirectory/$fileName" - memScoped { - val pointer = alloc>() + tasks[taskId] = task - try { - fileManager.moveItemAtURL( - srcURL = url, - toURL = NSURL(string = writablePath), - error = pointer.ptr - ) - progressListener?.invoke(url.toString(), 1) - } catch (e: Exception) { - e.printStackTrace() - errorListener?.invoke( - DownloadError(url.toString(), -1, "Error while file saving", httpStatusCode) - ) - } - } - } + // Start download + task.start() - val downloadTask = NSURLSession.sharedSession.downloadTaskWithURL( - url = NSURL(string = url), - completionHandler = ::complete - ) - val id = time(null) * 1000 - tasks[id] = downloadTask + return task + } - downloadTask.resume() + actual fun getTask(id: String): DownloadTask? = tasks[id] - return id + actual fun cancelAll() { + tasks.values.forEach { it.cancel() } + tasks.clear() + } + + private fun extractFileNameFromUrl(url: String): String { + return try { + val lastSegment = url.substringAfterLast("/") + if (lastSegment.isNotBlank() && lastSegment.contains(".")) { + lastSegment.substringBefore("?") + } else { + "${NSUUID().UUIDString}.bin" + } + } catch (e: Exception) { + "${NSUUID().UUIDString}.bin" + } } } diff --git a/shared/src/iosMain/kotlin/dev/onexeor/kdownloader/internal/IosDownloadTask.kt b/shared/src/iosMain/kotlin/dev/onexeor/kdownloader/internal/IosDownloadTask.kt new file mode 100644 index 0000000..6133b51 --- /dev/null +++ b/shared/src/iosMain/kotlin/dev/onexeor/kdownloader/internal/IosDownloadTask.kt @@ -0,0 +1,300 @@ +@file:OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) + +package dev.onexeor.kdownloader.internal + +import dev.onexeor.kdownloader.DownloadError +import dev.onexeor.kdownloader.DownloadListeners +import dev.onexeor.kdownloader.DownloadProgress +import dev.onexeor.kdownloader.DownloadRequest +import dev.onexeor.kdownloader.DownloadState +import dev.onexeor.kdownloader.DownloadTask +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.ObjCObjectVar +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.value +import platform.Foundation.NSDocumentDirectory +import platform.Foundation.NSError +import platform.Foundation.NSFileManager +import platform.Foundation.NSHTTPURLResponse +import platform.Foundation.NSMutableURLRequest +import platform.Foundation.NSSearchPathForDirectoriesInDomains +import platform.Foundation.NSURL +import platform.Foundation.NSURLResponse +import platform.Foundation.NSURLSession +import platform.Foundation.NSURLSessionConfiguration +import platform.Foundation.NSURLSessionDownloadTask +import platform.Foundation.NSUserDomainMask +import platform.Foundation.downloadTaskWithRequest +import platform.Foundation.setHTTPMethod +import platform.Foundation.setValue + +/** + * iOS implementation of DownloadTask using NSURLSession. + */ +internal class IosDownloadTask( + override val id: String, + override val request: DownloadRequest, + private val fileName: String, + private val directory: String?, + private val initialListeners: DownloadListeners +) : DownloadTask { + + private var _currentState: DownloadState = DownloadState.Pending + override val currentState: DownloadState get() = _currentState + + private var _currentProgress: DownloadProgress = DownloadProgress.ZERO + override val currentProgress: DownloadProgress get() = _currentProgress + + private var downloadTask: NSURLSessionDownloadTask? = null + private var filePath: String = "" + + private val progressListeners = mutableListOf<(DownloadProgress) -> Unit>() + private val stateListeners = mutableListOf<(DownloadState) -> Unit>() + private val completeListeners = mutableListOf<(String) -> Unit>() + private val errorListeners = mutableListOf<(DownloadError) -> Unit>() + + init { + initialListeners.onProgress?.let { progressListeners.add(it) } + initialListeners.onStateChange?.let { stateListeners.add(it) } + initialListeners.onComplete?.let { completeListeners.add(it) } + initialListeners.onError?.let { errorListeners.add(it) } + } + + override fun cancel() { + downloadTask?.cancel() + updateState(DownloadState.Cancelled) + notifyError(DownloadError.Cancelled) + } + + override fun onProgress(listener: (DownloadProgress) -> Unit): DownloadTask { + progressListeners.add(listener) + return this + } + + override fun onStateChange(listener: (DownloadState) -> Unit): DownloadTask { + stateListeners.add(listener) + return this + } + + override fun onComplete(listener: (String) -> Unit): DownloadTask { + completeListeners.add(listener) + return this + } + + override fun onError(listener: (DownloadError) -> Unit): DownloadTask { + errorListeners.add(listener) + return this + } + + fun start() { + val url = NSURL.URLWithString(request.url) + if (url == null) { + val error = DownloadError.InvalidUrl("Invalid URL: ${request.url}") + updateState(DownloadState.Failed(error)) + notifyError(error) + return + } + + val urlRequest = NSMutableURLRequest(uRL = url).apply { + setHTTPMethod("GET") + + // Add headers + request.headers.forEach { (key, value) -> + setValue(value, forHTTPHeaderField = key) + } + + // Add auth header + request.auth?.let { auth -> + val authHeader = when (auth) { + is dev.onexeor.kdownloader.Auth.Bearer -> "Bearer ${auth.token}" + is dev.onexeor.kdownloader.Auth.Basic -> { + val credentials = "${auth.username}:${auth.password}" + val encoded = encodeBase64(credentials) + "Basic $encoded" + } + } + setValue(authHeader, forHTTPHeaderField = "Authorization") + } + } + + val configuration = NSURLSessionConfiguration.defaultSessionConfiguration + val session = NSURLSession.sessionWithConfiguration(configuration) + + downloadTask = session.downloadTaskWithRequest(urlRequest) { location, response, error -> + handleCompletion(location, response, error) + } + + updateState(DownloadState.Downloading(_currentProgress)) + downloadTask?.resume() + } + + private fun handleCompletion(location: NSURL?, response: NSURLResponse?, error: NSError?) { + if (error != null) { + val downloadError = DownloadError.Network( + message = error.localizedDescription, + cause = null + ) + updateState(DownloadState.Failed(downloadError)) + notifyError(downloadError) + return + } + + val httpResponse = response as? NSHTTPURLResponse + val statusCode = httpResponse?.statusCode?.toInt() ?: 0 + + if (statusCode >= 400) { + val downloadError = DownloadError.Http( + statusCode = statusCode, + message = "HTTP error $statusCode" + ) + updateState(DownloadState.Failed(downloadError)) + notifyError(downloadError) + return + } + + if (location == null) { + val downloadError = DownloadError.Unknown("Download location is null") + updateState(DownloadState.Failed(downloadError)) + notifyError(downloadError) + return + } + + // Move file to documents directory + val documentsPath = getDocumentsDirectory() + val targetDir = if (directory != null) "$documentsPath/$directory" else documentsPath + val targetPath = "$targetDir/$fileName" + filePath = targetPath + + val fileManager = NSFileManager.defaultManager + + // Create directory if needed + memScoped { + val errorPtr = alloc>() + fileManager.createDirectoryAtPath( + path = targetDir, + withIntermediateDirectories = true, + attributes = null, + error = errorPtr.ptr + ) + } + + // Remove existing file if overwrite is enabled + if (request.overwrite && fileManager.fileExistsAtPath(targetPath)) { + memScoped { + val errorPtr = alloc>() + fileManager.removeItemAtPath(targetPath, errorPtr.ptr) + } + } + + // Move downloaded file to target location + memScoped { + val errorPtr = alloc>() + val destinationUrl = NSURL.fileURLWithPath(targetPath) + + val success = fileManager.moveItemAtURL( + srcURL = location, + toURL = destinationUrl, + error = errorPtr.ptr + ) + + if (success) { + _currentProgress = DownloadProgress( + bytesDownloaded = getFileSize(targetPath), + totalBytes = getFileSize(targetPath), + percentage = 100 + ) + updateState(DownloadState.Completed(targetPath)) + notifyComplete(targetPath) + } else { + val moveError = errorPtr.value + val downloadError = DownloadError.Storage( + message = moveError?.localizedDescription ?: "Failed to save file", + cause = null + ) + updateState(DownloadState.Failed(downloadError)) + notifyError(downloadError) + } + } + } + + private fun getDocumentsDirectory(): String { + val paths = NSSearchPathForDirectoriesInDomains( + NSDocumentDirectory, + NSUserDomainMask, + true + ) + return paths.firstOrNull() as? String ?: "" + } + + private fun getFileSize(path: String): Long { + return try { + val fileManager = NSFileManager.defaultManager + memScoped { + val errorPtr = alloc>() + val attributes = fileManager.attributesOfItemAtPath(path, errorPtr.ptr) + (attributes?.get("NSFileSize") as? Number)?.toLong() ?: 0L + } + } catch (e: Exception) { + 0L + } + } + + private fun encodeBase64(input: String): String { + // Simple base64 encoding implementation + val base64Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + val bytes = input.encodeToByteArray() + val result = StringBuilder() + + var i = 0 + while (i < bytes.size) { + val b0 = bytes[i].toInt() and 0xFF + val b1 = if (i + 1 < bytes.size) bytes[i + 1].toInt() and 0xFF else 0 + val b2 = if (i + 2 < bytes.size) bytes[i + 2].toInt() and 0xFF else 0 + + result.append(base64Chars[b0 shr 2]) + result.append(base64Chars[((b0 and 0x03) shl 4) or (b1 shr 4)]) + + if (i + 1 < bytes.size) { + result.append(base64Chars[((b1 and 0x0F) shl 2) or (b2 shr 6)]) + } else { + result.append('=') + } + + if (i + 2 < bytes.size) { + result.append(base64Chars[b2 and 0x3F]) + } else { + result.append('=') + } + + i += 3 + } + + return result.toString() + } + + private fun updateState(newState: DownloadState) { + if (_currentState != newState) { + _currentState = newState + notifyState(newState) + } + } + + private fun notifyProgress(progress: DownloadProgress) { + progressListeners.forEach { it(progress) } + } + + private fun notifyState(state: DownloadState) { + stateListeners.forEach { it(state) } + } + + private fun notifyComplete(path: String) { + completeListeners.forEach { it(path) } + } + + private fun notifyError(error: DownloadError) { + errorListeners.forEach { it(error) } + } +}