Pause and resume downloads using Retrofit

后端 未结 2 1923
礼貌的吻别
礼貌的吻别 2020-12-30 15:49

I used this tutorial to implement downloading files in my app: https://www.learn2crack.com/2016/05/downloading-file-using-retrofit.html

The problem is that if intern

2条回答
  •  情书的邮戳
    2020-12-30 16:51

    I also faced this problem today and didn't find any good solutions which implemented at once download resumes, progress notifications and BufferedSink usage for fast nio operations.

    This is how it could be done with Retrofit2 and RxJava2. Code is witten in Kotlin for Android, but it can be easily ported to pure JVM: just get rid from AndroidSchedulers

    Code may contain bugs as it was written from scratch in short time and was barely tested.

    import com.google.gson.GsonBuilder
    import io.reactivex.Observable
    import io.reactivex.ObservableEmitter
    import io.reactivex.ObservableOnSubscribe
    import io.reactivex.android.schedulers.AndroidSchedulers
    import io.reactivex.functions.Consumer
    import io.reactivex.functions.Function
    import io.reactivex.schedulers.Schedulers
    import okhttp3.OkHttpClient
    import okhttp3.ResponseBody
    import okio.Buffer
    import okio.BufferedSink
    import okio.ForwardingSource
    import okio.Okio
    import org.slf4j.LoggerFactory
    import retrofit2.Response
    import retrofit2.Retrofit
    import retrofit2.converter.gson.GsonConverterFactory
    import retrofit2.http.GET
    import retrofit2.http.Header
    import retrofit2.http.Streaming
    import retrofit2.http.Url
    import java.io.File
    import java.io.IOException
    import java.util.concurrent.ConcurrentHashMap
    import java.util.regex.Pattern
    
    class FileDownloader(val baseUrl: String) {
    
        private val log = LoggerFactory.getLogger(FileDownloader::class.java)
    
        private val expectedFileLength = ConcurrentHashMap()
        private val eTag = ConcurrentHashMap()
    
        private val apiChecker: FileDownloaderAPI
    
        init {
            apiChecker = Retrofit.Builder()
                    .baseUrl(baseUrl)
                    .client(OkHttpClient())
                    .addConverterFactory(GsonConverterFactory.create(GsonBuilder().setLenient().create()))
                    .build()
                    .create(FileDownloaderAPI::class.java)
    
        }
    
    
        /**
         *
         * @return File Observable
         */
        fun download(
                urlPath: String,
                file: File,
                dlProgressConsumer: Consumer): Observable {
            return Observable.create(ObservableOnSubscribe {
                val downloadObservable: Observable
    
                if (file.exists() &&
                        file.length() > 0L &&
                        file.length() != expectedFileLength[file.name]
                        ) {
                    /**
                     * Try to get rest of the file according to:
                     * http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html
                     */
                    downloadObservable = apiChecker.downloadFile(
                            urlPath,
                            "bytes=${file.length()}-",
                            eTag[file.name] ?: "0"
                    ).flatMap(
                            DownloadFunction(file, it)
                    )
                } else {
                    /**
                     * Last time file was fully downloaded or not present at all
                     */
                    if (!file.exists())
                        eTag[file.name] = ""
    
                    downloadObservable = apiChecker.downloadFile(
                            urlPath,
                            eTag[file.name] ?: "0"
                    ).flatMap(
                            DownloadFunction(file, it)
                    )
    
                }
    
                downloadObservable
                        .observeOn(AndroidSchedulers.mainThread())
                        .subscribe(dlProgressConsumer)
    
            }).subscribeOn(Schedulers.io())
                    .observeOn(AndroidSchedulers.mainThread())
        }
    
        private inner class DownloadFunction(
                val file: File,
                val fileEmitter: ObservableEmitter
        ) : Function, Observable> {
    
            var contentLength = 0L
    
            var startingByte = 0L
            var endingByte = 0L
            var totalBytes = 0L
    
    
            var contentRangePattern = "bytes ([0-9]*)-([0-9]*)/([0-9]*)"
            fun parseContentRange(contentRange: String) {
                val matcher = Pattern.compile(contentRangePattern).matcher(contentRange)
                if (matcher.find()) {
                    startingByte = matcher.group(1).toLong()
                    endingByte = matcher.group(2).toLong()
                    totalBytes = matcher.group(3).toLong()
                }
            }
    
            var totalRead = 0L
    
            var lastPercentage = 0
    
            override fun apply(response: Response): Observable {
                return Observable.create { subscriber ->
                    try {
                        if (!response.isSuccessful) {
                            /**
                             * Including response 304 Not Modified
                             */
                            fileEmitter.onError(IllegalStateException("Code: ${response.code()}, ${response.message()}; Response $response"))
                            return@create
                        }
    
                        contentLength = response.body().contentLength()
    
    
                        log.info("{}", response)
                        /**
                         * Receiving partial content, which in general means that download is resumed
                         */
                        if (response.code() == 206) {
                            parseContentRange(response.headers().get("Content-Range"))
                            log.debug("Getting range from {} to {} of {} bytes", startingByte, endingByte, totalBytes)
                        } else {
                            endingByte = contentLength
                            totalBytes = contentLength
                            if (file.exists())
                                file.delete()
                        }
    
                        log.info("Starting byte: {}, ending byte {}", startingByte, endingByte)
    
                        totalRead = startingByte
    
                        eTag.put(file.name, response.headers().get("ETag"))
                        expectedFileLength.put(file.name, totalBytes)
    
    
                        val sink: BufferedSink
                        if (startingByte > 0) {
                            sink = Okio.buffer(Okio.appendingSink(file))
                        } else {
                            sink = Okio.buffer(Okio.sink(file))
                        }
    
                        sink.use {
                            it.writeAll(object : ForwardingSource(response.body().source()) {
    
                                override fun read(sink: Buffer, byteCount: Long): Long {
                                    val bytesRead = super.read(sink, byteCount)
    
                                    totalRead += bytesRead
    
                                    /**
                                     * May not wok good if we get some shit from the middle of the file,
                                     * though that's not the case of this function, as we plan only to
                                     * resume downloads
                                     */
                                    val currentPercentage = (totalRead * 100 / totalBytes).toInt()
                                    if (currentPercentage > lastPercentage) {
                                        val progress = "$currentPercentage%"
                                        lastPercentage = currentPercentage
                                        subscriber.onNext(currentPercentage)
                                        log.debug("Downloading {} progress: {}", file.name, progress)
                                    }
                                    return bytesRead
                                }
                            })
                        }
    
                        subscriber.onComplete()
                        fileEmitter.onNext(file)
                        fileEmitter.onComplete()
                    } catch (e: IOException) {
                        log.error("Last percentage: {}, Bytes read: {}", lastPercentage, totalRead)
                        fileEmitter.onError(e)
                    }
                }
            }
    
        }
    
        interface FileDownloaderAPI {
    
    
            @Streaming @GET
            fun downloadFile(
                    @Url fileUrl: String,
                    @Header("If-None-Match") eTag: String
            ): Observable>
    
            @Streaming @GET
            fun downloadFile(
                    @Url fileUrl: String,
    
                    // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35
                    @Header("Range") bytesRange: String,
    
                    // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.27
                    @Header("If-Range") eTag: String
            ): Observable>
        }
    }
    

    And then use it where you want

        val fileDownloader = FileDownloader("http://wwww.example.com")
        fileDownloader.download(
                "/huge-video.mkv",
                File("file-where-I-will-save-this-video.mkv"),
                Consumer { progress ->
                    updateProgressNotificatuin()
                }
        ).subscribe({
            log.info("File saved at path {}", it.absolutePath)
        },{
            log.error("Download error {}", it.message, it)
        },{
            log.info("Download completed")
        })
    

    Dependencies used in this sample:

    dependencies {
        compile "org.jetbrains.kotlin:kotlin-stdlib:1.1.1"
        compile 'io.reactivex.rxjava2:rxandroid:2.0.1'
    
        compile 'com.squareup.retrofit2:retrofit:2.2.0'
        compile 'com.squareup.retrofit2:converter-gson:2.2.0'
        compile 'com.squareup.retrofit2:adapter-rxjava2:2.2.0'
        compile 'com.google.code.gson:gson:2.7'
    
    
        compile 'org.slf4j:slf4j-api:1.7.25'
    }
    

提交回复
热议问题