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
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
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'
}