All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.org.dbtools.kmp.commons.download.DirectDownloader.kt Maven / Gradle / Ivy

The newest version!
@file:Suppress("MemberVisibilityCanBePrivate")

package org.dbtools.kmp.commons.download

import co.touchlab.kermit.Logger
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.prepareGet
import io.ktor.http.contentLength
import io.ktor.http.headers
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.readRemaining
import kotlinx.atomicfu.atomic
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import kotlinx.io.Source
import kotlinx.io.readByteArray
import okio.buffer
import okio.use
import kotlin.time.TimeSource.Monotonic.markNow

/**
 * DirectDownloader
 *
 * Provides ability to download a file directly to a target path using a DownloadRequest
 */
class DirectDownloader {
    val inProgress = atomic(false)
    private var cancelRequested = false

    private val _progressStateFlow = MutableStateFlow(DirectDownloadProgress.Enqueued)
    val progressStateFlow: StateFlow = _progressStateFlow

    /**
     * Download File
     * @param httpClient Ktor HttpClient
     * @param directDownloadRequest Request info for downloader
     * @param dispatcher Coroutine Dispatcher to be used for the download
     *
     * @return DirectDownloadResult containing success flag and possible messages
     */
    suspend fun download(
        httpClient: HttpClient,
        directDownloadRequest: DirectDownloadRequest,
        dispatcher: CoroutineDispatcher = Dispatchers.IO
    ): DirectDownloadResult = withContext(dispatcher) {
        if (!inProgress.compareAndSet(expect = false, update = true)) {
            return@withContext DirectDownloadResult(false, "Download already in progress")
        }

        val directDownloadResult: DirectDownloadResult = downloadFile(
            httpClient = httpClient,
            directDownloadRequest = directDownloadRequest
        ) { totalBytesRead, contentLength ->
            _progressStateFlow.value = DirectDownloadProgress.Downloading(totalBytesRead, contentLength)
        }

        _progressStateFlow.value = DirectDownloadProgress.DownloadComplete(directDownloadResult.success, directDownloadResult.message)

        return@withContext directDownloadResult
    }

    private suspend fun downloadFile(
        httpClient: HttpClient,
        directDownloadRequest: DirectDownloadRequest,
        updateProgress: (totalBytesRead: Long, contentLength: Long) -> Unit,
    ): DirectDownloadResult {
        val mark = markNow()

        // make sure target file doesn't already exist
        val prepareTargetFileResult = prepareTargetFile(directDownloadRequest)
        if (prepareTargetFileResult != null) {
            return prepareTargetFileResult
        }

        val directDownloadResult: DirectDownloadResult = try {
            val httpStatement = httpClient.prepareGet(directDownloadRequest.downloadUrl) {
                // add any custom headers
                headers {
                    directDownloadRequest.customHeaders?.forEach { directDownloadHeader ->
                        append(directDownloadHeader.name, directDownloadHeader.value)
                    }
                }
            }

            // execute and download
            httpStatement.execute { httpResponse ->
                // Parse Content-Length header value.
                val contentLength = httpResponse.contentLength() ?: 0L

                directDownloadRequest.fileSystem.sink(directDownloadRequest.targetFile).buffer().use { outputFileBufferSink ->
                    @Suppress("UNUSED_VARIABLE") // used to provide sum to "updateProgress(...)
                    var totalBytesRead = 0L
                    val channel: ByteReadChannel = httpResponse.body()
                    while (!channel.isClosedForRead) {
                        if (cancelRequested) {
                            return@execute DirectDownloadResult(false, "Download canceled")
                        }

                        val source: Source = channel.readRemaining(DEFAULT_BUFFER_SIZE.toLong())
                        while (!source.exhausted()) {
                            val bytes = source.readByteArray()

                            outputFileBufferSink.write(bytes)

                            // update totalBytesRead
                            totalBytesRead += bytes.size

                            // update progress
                            if (directDownloadRequest.trackProgress && totalBytesRead % directDownloadRequest.trackProgressUpdateIntervalSize == 0L) {
                                updateProgress(totalBytesRead, contentLength)
                            }
                        }
                    }
                }
                Logger.i { "A file saved to ${directDownloadRequest.targetFile}" }
                DirectDownloadResult(success = true)
            }
        } catch (expected: Exception) {
            DirectDownloadResult(success = false, expected.message)
        }

        // verify result
        if (directDownloadResult.success && !directDownloadRequest.fileSystem.exists(directDownloadRequest.targetFile)) {
            val message = "Download was successful, but the target file does not exist (${directDownloadRequest.targetFile})"
            return DirectDownloadResult(false, message)
        }

        Logger.i { "Download complete for: ${directDownloadRequest.targetFile} (${mark.elapsedNow()})" }

        return directDownloadResult
    }

    /**
     * Make sure target directory exists, and target file does NOT yet exist
     */
    @Suppress("ReturnCount") // all return points are valid and needed
    private fun prepareTargetFile(directDownloadRequest: DirectDownloadRequest): DirectDownloadResult? {
        val fileSystem = directDownloadRequest.fileSystem
        val targetFile = directDownloadRequest.targetFile

        // make sure target directory exists
        try {
            val targetDirectory = targetFile.parent ?: return DirectDownloadResult(false, "Failed to prepareTargetFile target directory == null")

            if (!fileSystem.exists(targetDirectory)) {
                fileSystem.createDirectories(targetDirectory)
                if (!fileSystem.exists(targetDirectory)) {
                    return DirectDownloadResult(false, "Failed to create target directory: [${targetDirectory}]")
                }
            }
        } catch (expected: Exception) {
            val message = "Failed to create target directory: [${targetFile.parent}]  message: [${expected.message}]"
            Logger.e(expected) { message }
            return DirectDownloadResult(false, message)
        }

        // check to see if target file exists
        if (fileSystem.exists(targetFile)) {
            if (directDownloadRequest.overwriteExisting) {
                try {
                    fileSystem.delete(targetFile)
                } catch (expected: Exception) {
                    val message = "Failed to delete existing target file: [${targetFile}]  message: [${expected.message}]"
                    Logger.e(expected) { message }
                    return DirectDownloadResult(false, message)
                }
            } else {
                return DirectDownloadResult(false, "Failed download to target file...  target file already exists: [${targetFile}]  (overwriteExisting == false)")
            }
        }

        // if we get to this point... all is well! (target directory exists, and target file does NOT yet exist)
        return null
    }

    fun cancel() {
        cancelRequested = true
    }

    companion object {
        const val DEFAULT_BUFFER_SIZE = 8 * 1024
        const val DEFAULT_PROGRESS_UPDATE_BYTE_SIZE = 1000L
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy