Pause and resume downloads using Retrofit

后端 未结 2 1913
礼貌的吻别
礼貌的吻别 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:33

    This is my Kotlin implementation, inspired by Клаус Шварц:

    i used Coroutines because they make the code very easy to read and use; i also used ru.gildor.coroutines:kotlin-coroutines-retrofit to add coroutine support to retrofit.

    import okhttp3.OkHttpClient
    import okhttp3.ResponseBody
    import okhttp3.logging.HttpLoggingInterceptor
    import okio.Buffer
    import okio.BufferedSink
    import okio.ForwardingSource
    import okio.Okio
    import retrofit2.Call
    import retrofit2.HttpException
    import retrofit2.Response
    import retrofit2.Retrofit
    import retrofit2.http.GET
    import retrofit2.http.HeaderMap
    import retrofit2.http.Streaming
    import retrofit2.http.Url
    import ru.gildor.coroutines.retrofit.awaitResponse
    import java.io.File
    import java.util.concurrent.TimeUnit
    import java.util.regex.Pattern
    
    
    object FileDownloader{
    
        private val Service by lazy { serviceBuilder().create<FileDownloaderInterface>(FileDownloaderInterface::class.java) }
    
        val baseUrl = "http://www.your-website-base-url.com"
    
        private fun serviceBuilder(): Retrofit {
            //--- OkHttp client ---//
            val okHttpClient = OkHttpClient.Builder()
                    .readTimeout(60, TimeUnit.SECONDS)
                    .connectTimeout(60, TimeUnit.SECONDS)
    
            //--- Add authentication headers ---//
            okHttpClient.addInterceptor { chain ->
                val original = chain.request()
    
                // Just some example headers
                val requestBuilder = original.newBuilder()
                        .addHeader("Connection","keep-alive")
                        .header("User-Agent", "downloader")
    
                val request = requestBuilder.build()
                chain.proceed(request)
            }
    
            //--- Add logging ---//
    
            if (BuildConfig.DEBUG) {
                // development build
                val logging = HttpLoggingInterceptor()
                logging.setLevel(HttpLoggingInterceptor.Level.BASIC)
                // NOTE: do NOT use request BODY logging or it will not work!
    
                okHttpClient.addInterceptor(logging)
            }
    
    
            //--- Return Retrofit class ---//
            return Retrofit.Builder()
                    .client(okHttpClient.build())
                    .baseUrl(baseUrl)
                    .build()
        }
    
        suspend fun downloadOrResume(
                url:String, destination: File,
                headers:HashMap<String,String> = HashMap<String,String>(),
                onProgress: ((percent: Int, downloaded: Long, total: Long) -> Unit)? = null
            ){
    
            var startingFrom = 0L
            if(destination.exists() && destination.length()>0L){
                startingFrom = destination.length()
                headers.put("Range","bytes=${startingFrom}-")
            }
            println("Download starting from $startingFrom - headers: $headers")
    
            download(url,destination,headers,onProgress)
        }
    
        suspend fun download(
                url:String,
                destination: File,
                headers:HashMap<String,String> = HashMap<String,String>(),
                onProgress: ((percent: Int, downloaded: Long, total: Long) -> Unit)? = null
            ) {
            println("---------- downloadFileByUrl: getting response -------------")
            val response = Service.downloadFile(url,headers).awaitResponse()
            handleDownloadResponse(response,destination,onProgress)
        }
    
        fun handleDownloadResponse(
                response:Response<ResponseBody>,
                destination:File,
                onProgress: ((percent: Int, downloaded: Long, total: Long) -> Unit)?
        ) {
            println("-- downloadFileByUrl: parsing response! $response")
    
    
            var startingByte = 0L
            var endingByte = 0L
            var totalBytes = 0L
    
    
            if(!response.isSuccessful) {
                throw HttpException(response)
                //java.lang.IllegalStateException: Error downloading file: 416, Requested Range Not Satisfiable; Response Response{protocol=http/1.1, code=416, message=Requested Range Not Satisfiable, u
            }
            val contentLength = response.body()!!.contentLength()
    
            if (response.code() == 206) {
                println("- http 206: Continue download")
                val matcher = Pattern.compile("bytes ([0-9]*)-([0-9]*)/([0-9]*)").matcher(response.headers().get("Content-Range"))
                if (matcher.find()) {
                    startingByte = matcher.group(1).toLong()
                    endingByte = matcher.group(2).toLong()
                    totalBytes = matcher.group(3).toLong()
                }
                println("Getting range from $startingByte to ${endingByte} of ${totalBytes} bytes" )
            } else {
                println("- new download")
                endingByte = contentLength
                totalBytes = contentLength
                if (destination.exists()) {
                    println("Delete previous download!")
                    destination.delete()
                }
            }
    
    
            println("Getting range from $startingByte to ${endingByte} of ${totalBytes} bytes" )
            val sink: BufferedSink
            if (startingByte > 0) {
                sink = Okio.buffer(Okio.appendingSink(destination))
            } else {
                sink = Okio.buffer(Okio.sink(destination))
            }
    
    
            var lastPercentage=-1
            var totalRead=startingByte
            sink.use {
                it.writeAll(object : ForwardingSource(response.body()!!.source()) {
    
                    override fun read(sink: Buffer, byteCount: Long): Long {
                        //println("- Reading... $byteCount")
                        val bytesRead = super.read(sink, byteCount)
    
                        totalRead += bytesRead
    
                        val currentPercentage = (totalRead * 100 / totalBytes).toInt()
                        //println("Progress: $currentPercentage - $totalRead")
                        if (currentPercentage > lastPercentage) {
                            lastPercentage = currentPercentage
                            if(onProgress!=null){
                                onProgress(currentPercentage,totalRead,totalBytes)
                            }
                        }
                        return bytesRead
                    }
                })
            }
    
            println("--- Download complete!")
        }
    
        internal interface FileDownloaderInterface{
            @Streaming
            @GET
            fun downloadFile(
                    @Url fileUrl: String,
                    @HeaderMap headers:Map<String,String>
            ): Call<ResponseBody>
        }
    }
    

    Example usage:

        val url = "https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/debian-9.4.0-amd64-xfce-CD-1.iso"
        val destination = File(context.filesDir, "debian-9.4.0-amd64-xfce-CD-1.iso")
    
        //Optional: you can also add custom headers
        val headers = HashMap<String,String>()
    
        try {
            // Start or continue a download, catch download exceptions
            FileDownloader.downloadOrResume(
                    url,
                    destination,
                    headers,
                    onProgress = { progress, read, total ->
                        println(">>> Download $progress% ($read/$total b)")
                    });
        }catch(e: SocketTimeoutException){
            println("Download socket TIMEOUT exception: $e")
        }catch(e: SocketException){
            println("Download socket exception: $e")
        }catch(e: HttpException){
            println("Download HTTP exception: $e")
        }
    

    Gradle dependencies

    dependencies {
        /** Retrofit 2 **/
        compile 'com.squareup.retrofit2:retrofit:2.4.0'
    
        // OkHttp for Retrofit request customization
        compile 'com.squareup.okhttp3:okhttp:3.10.0'
    
        // For http request logging
        compile 'com.squareup.okhttp3:logging-interceptor:3.10.0'
    
        // Retrofit Kotlin coroutines support
        compile 'ru.gildor.coroutines:kotlin-coroutines-retrofit:0.9.0'
    }
    

    Note: Kotlin coroutines must be enabled, at the moment they need to be enabled as experimental

    0 讨论(0)
  • 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<String, Long>()
        private val eTag = ConcurrentHashMap<String, String>()
    
        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<Int>): Observable<File> {
            return Observable.create(ObservableOnSubscribe<File> {
                val downloadObservable: Observable<Int>
    
                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<File>
        ) : Function<Response<ResponseBody>, Observable<Int>> {
    
            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<ResponseBody>): Observable<Int> {
                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<Response<ResponseBody>>
    
            @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<Response<ResponseBody>>
        }
    }
    

    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'
    }
    
    0 讨论(0)
提交回复
热议问题