18

I'm new to retrofit. I've searched but didn't found a simple answer. I want to know how can I show progress of download in Notification bar or at least show a progress dialog which specifies the percent of process and size of downloading file. Here is my code:

public interface ServerAPI {
    @GET
    Call<ResponseBody> downlload(@Url String fileUrl);

    Retrofit retrofit =
            new Retrofit.Builder()
                    .baseUrl("http://192.168.43.135/retro/") 
                    .addConverterFactory(GsonConverterFactory.create())
                    .build();

}

public void download(){
    ServerAPI api = ServerAPI.retrofit.create(ServerAPI.class);
    api.downlload("https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_120x44dp.png").enqueue(new Callback<ResponseBody>() {
        @Override
        public void onResponse(Call<ResponseBody> call, Response<ResponseBody> response) {
            try {
                File path = Environment.getExternalStorageDirectory();
                File file = new File(path, "file_name.jpg");
                FileOutputStream fileOutputStream = new FileOutputStream(file);
                IOUtils.write(response.body().bytes(), fileOutputStream);
            }
            catch (Exception ex){
            }
        }


        @Override
        public void onFailure(Call<ResponseBody> call, Throwable t) {
        }
    });
}

please guide me if you can. thanks

Saman
  • 211
  • 1
  • 2
  • 7
  • it was a good solution to solve the problem:https://stackoverflow.com/questions/41892696/is-it-possible-to-show-progress-bar-when-download-via-retrofit-2-asynchronous/49398941#49398941 and I have test it,may it can help you – MichaelZ Mar 21 '18 at 05:46

5 Answers5

22

You need to create a specific OkHttp client which will intercept the network requests and send updates. This client should only be used for downloads.

First you are going to need an interface, like this one:

public interface OnAttachmentDownloadListener {
    void onAttachmentDownloadedSuccess();
    void onAttachmentDownloadedError();
    void onAttachmentDownloadedFinished();
    void onAttachmentDownloadUpdate(int percent);
}

Your download call should return a ResponseBody, which we will extend from to be able to get the download progress.

private static class ProgressResponseBody extends ResponseBody {

    private final ResponseBody responseBody;
    private final OnAttachmentDownloadListener progressListener;
    private BufferedSource bufferedSource;

    public ProgressResponseBody(ResponseBody responseBody, OnAttachmentDownloadListener progressListener) {
        this.responseBody = responseBody;
        this.progressListener = progressListener;
    }

    @Override public MediaType contentType() {
        return responseBody.contentType();
    }

    @Override public long contentLength() {
        return responseBody.contentLength();
    }

    @Override public BufferedSource source() {
        if (bufferedSource == null) {
            bufferedSource = Okio.buffer(source(responseBody.source()));
        }
        return bufferedSource;
    }

    private Source source(Source source) {
        return new ForwardingSource(source) {
            long totalBytesRead = 0L;

            @Override public long read(Buffer sink, long byteCount) throws IOException {
                long bytesRead = super.read(sink, byteCount);

                totalBytesRead += bytesRead != -1 ? bytesRead : 0;

                float percent = bytesRead == -1 ? 100f : (((float)totalBytesRead / (float) responseBody.contentLength()) * 100);

                if(progressListener != null)
                    progressListener.onAttachmentDownloadUpdate((int)percent);

                return bytesRead;
            }
        };
    }
}

Then you will need to create your OkHttpClient like this

public OkHttpClient.Builder getOkHttpDownloadClientBuilder(OnAttachmentDownloadListener progressListener) {
    OkHttpClient.Builder httpClientBuilder = new OkHttpClient.Builder();

    // You might want to increase the timeout
    httpClientBuilder.connectTimeout(20, TimeUnit.SECONDS);
    httpClientBuilder.writeTimeout(0, TimeUnit.SECONDS);
    httpClientBuilder.readTimeout(5, TimeUnit.MINUTES);

    httpClientBuilder.addInterceptor(new Interceptor() {
        @Override
        public Response intercept(Chain chain) throws IOException {
            if(progressListener == null) return chain.proceed(chain.request());

        Response originalResponse = chain.proceed(chain.request());
        return originalResponse.newBuilder()
                .body(new ProgressResponseBody(originalResponse.body(), progressListener))
                .build();
        }
    });

    return httpClientBuilder;
}

Finally you only have to create your Retrofit client a different way, by passing your new OkHttp client. Based on your code, you can use something like this:

 public Retrofit getDownloadRetrofit(OnAttachmentDownloadListener listener) {

    return new Retrofit.Builder()
                .baseUrl("http://192.168.43.135/retro/") 
                .addConverterFactory(GsonConverterFactory.create())
                .client(getOkHttpDownloadClientBuilder(listener).build())
                .build();

}

Your listener will handle the creation of your notification or whatever else you want.

maxoumime
  • 3,441
  • 1
  • 17
  • 23
  • `onAttachmentDownloadUpdate()` recieved after `onResponse()`. it means file is downloaded. – Divy Soni Aug 26 '20 at 08:38
  • This approach may cause OutOfMemoryError for large files (for me it was 400+mb). I ended up using this answer: https://stackoverflow.com/a/65588935/7035703 – anro Jul 27 '21 at 11:36
17

Here is another Kotlin solution using Flow

interface MyService {
    @Streaming // allows streaming data directly to fs without holding all contents in ram
    @GET
    suspend fun getUrl(@Url url: String): ResponseBody
}

sealed class Download {
    data class Progress(val percent: Int) : Download()
    data class Finished(val file: File) : Download()
}

fun ResponseBody.downloadToFileWithProgress(directory: File, filename: String): Flow<Download> =
    flow {
        emit(Download.Progress(0))

        // flag to delete file if download errors or is cancelled
        var deleteFile = true
        val file = File(directory, "${filename}.${contentType()?.subtype}")

        try {
            byteStream().use { inputStream ->
                file.outputStream().use { outputStream ->
                    val totalBytes = contentLength()
                    val data = ByteArray(8_192)
                    var progressBytes = 0L

                    while (true) {
                        val bytes = inputStream.read(data)

                        if (bytes == -1) {
                            break
                        }

                        outputStream.write(data, 0, bytes)
                        progressBytes += bytes

                        emit(Download.Progress(percent = ((progressBytes * 100) / totalBytes).toInt()))
                    }

                    when {
                        progressBytes < totalBytes ->
                            throw Exception("missing bytes")
                        progressBytes > totalBytes ->
                            throw Exception("too many bytes")
                        else ->
                            deleteFile = false
                    }
                }
            }

            emit(Download.Finished(file))
        } finally {
            // check if download was successful

            if (deleteFile) {
                file.delete()
            }
        }
    }
        .flowOn(Dispatchers.IO)
        .distinctUntilChanged()

suspend fun Context.usage() {
    coroutineScope {
        myService.getUrl("https://www.google.com")
            .downloadToFileWithProgress(
                externalCacheDir!!,
                "my_file",
            )
            .collect { download ->
                when (download) {
                    is Download.Progress -> {
                        // update ui with progress
                    }
                    is Download.Finished -> {
                        // update ui with file
                    }
                }
            }
    }
}
Robert C.
  • 311
  • 2
  • 6
  • Why are you doing `${contentType()?.subtype}` ? – nimi0112 Jan 19 '21 at 03:00
  • 4
    contentType() is something like "video/mp4" and subtype is the "mp4" part. – Robert C. Jan 20 '21 at 13:59
  • This is such a great answer! Lovely code, a doddle to use in the View layer, make use of all latest best practice, sealed classes, love it. I'd give this an upvote of 20 if I was allowed. – Vin Norman Feb 06 '22 at 07:22
  • Excellent answer! What is this line for: `outputStream.channel`? It looks like it's not doing anything – Granjero Jun 13 '22 at 17:59
  • `outputStream.channel` doesn't do anything. Must have left that in there by mistake. Good catch @Granjero – Robert C. Jun 15 '22 at 01:06
  • 1
    Problem is that ResponseBody returns only after the full download of the file. Then this flow returns the progress of writing the file to the disk which is not the required download progress. – Amr Aug 24 '22 at 01:48
4

Here is my variant with Kotlin's coroutines

  1. Specify API interface. We need @Streaming annotation to say Retrofit that we want to handle the response body manually. Otherwise, retrofit will try to write your file straight into RAM
interface Api {

    @Streaming
    @GET("get-zip-ulr/{id}")
    fun getZip(@Path("id") id: Int): Call<ResponseBody>
}
  1. Create DataSource which will control downloading process
class FilesDataSource(private val parentFolder: File, private val api: Api) {

    suspend fun downloadZip(id: Int, processCallback: (Long, Long) -> Unit): File {
        val response = api.getZip(id).awaitResponse()// returns the response, but it's content will be later
        val body = response.body()
        if (response.isSuccessful && body != null) {
            val file = File(parentFolder, "$id")
            body.byteStream().use { inputStream ->
                FileOutputStream(file).use { outputStream ->
                    val data = ByteArray(8192)
                    var read: Int
                    var progress = 0L
                    val fileSize = body.contentLength()
                    while (inputStream.read(data).also { read = it } != -1) {
                        outputStream.write(data, 0, read)
                        progress += read
                        publishProgress(processCallback, progress, fileSize)
                    }
                    publishProgress(processCallback, fileSize, fileSize)
                }
            }
            return file
        } else {
            throw HttpException(response)
        }
    }

    private suspend fun publishProgress(
        callback: (Long, Long) -> Unit,
        progress: Long, //bytes
        fileSize: Long  //bytes
    ) {
        withContext(Dispatchers.Main) { // invoke callback in UI thtread
            callback(progress, fileSize)
        }
    }
}

Now you can execute downloadZip() method in your ViewModel or Presenter and give it a callback which will be linked to some ProgerssBar. After download completion, you will receive the downloaded file.

  • 3
    doesn't it load the data first & then give the input stream? because when I have added the `HttpLoggingInterceptor` to retrofit, I see all bytes are downloaded first via stream and the progress is not actual download progress but copy from the input stream to another. Let me know if I am incorrect. – NAUSHAD Aug 06 '20 at 20:44
  • I have the same behaviour as @NAUSHAD. The direct call of Response.body() downloads the file. So, I am not quite sure how does this work. – Ov3r1oad Jul 06 '22 at 19:16
3

None of provided answers works correctly, here is a working solution which combine both approach correctly.
First create new retrofit object for download, this retrofit object shouldn't contain any log interceptor because this will cause java.lang.IllegalStateException: closed

fun interface ResponseBodyListener {
    fun update(responseBody: ResponseBody)
}

fun getDownloaderRetrofit(listener: ResponseBodyListener): Retrofit = Retrofit.Builder()
    .baseUrl("https://example.com")// <-- this is just a placeholder, we will not use it either way in the request.
    .client(initHttpDownloadListenerClient(listener))
    .build()

private fun initHttpDownloadListenerClient(listener: ResponseBodyListener): OkHttpClient {
    return OkHttpClient.Builder()
        .connectTimeout(20, TimeUnit.SECONDS)
        .readTimeout(5, TimeUnit.MINUTES)
        .writeTimeout(0, TimeUnit.SECONDS)
        .addNetworkInterceptor { chain ->
            chain.proceed(chain.request()).also { originalResponse ->
                originalResponse.body?.let { listener.update(it) }
            }
        }
        .build()
}

in addNetworkInterceptor we get the response body as soon as it becomes available so that we track the actual download progress. It has stream of data being downloaded data from server.

Here is api interface

interface DownloadFilesApi {
    @GET
    @Streaming
    suspend fun downloadFile(@Url url: String): ResponseBody
}

Here is the request itself

@OptIn(ExperimentalCoroutinesApi::class)
override suspend fun downloadFile(url: String, directory: File, fileName: String): Flow<DownloadFileState> =
    callbackFlow {
        val listener = ResponseBodyListener { responseBody: ResponseBody ->
            this.launch {
                responseBody.downloadToFileWithProgress(directory, fileName).collect {
                    trySend(it)
                }
            }
        }
        getDownloaderRetrofit(listener).create(DownloadFilesApi::class.java).downloadFile(url)
        awaitClose()
    }

Notice that downloadFile is suspend this suspends the coroutine until it finishes download.
here callbackFlow is used to work as bridge between the normal callback and flow result.

finally downloadToFileWithProgress is the same as written by @Robert the difference is that here it shows progress of downloading the file instead of showing progress of writing the file on desk after the actual download finishes.
for reference here it's RetrofitExtentions.kt

    fun ResponseBody.downloadToFileWithProgress(directory: File, filename: String): Flow<DownloadFileState> = flow {
        emit(DownloadFileState.Progress(0))

        // flag to delete file if download errors or is cancelled
        var deleteFile = true
        val file = File(directory, filename)

        try {
            byteStream().use { inputStream ->
                file.outputStream().use { outputStream ->
                    val totalBytes = contentLength()
                    val data = ByteArray(8_192)
                    var progressBytes = 0L

                    while (true) {
                        val bytes = inputStream.read(data)
                        if (bytes == -1) {
                            break
                        }

                        outputStream.write(data, 0, bytes)
                        progressBytes += bytes

                        val progress = ((progressBytes * 100) / totalBytes).toInt()
                        emit(DownloadFileState.Progress(percent = progress))
                    }

                    when {
                        progressBytes < totalBytes ->
                            throw Exception("missing bytes")
                        progressBytes > totalBytes ->
                            throw Exception("too many bytes")
                        else ->
                            deleteFile = false
                    }
                }
            }

            emit(DownloadFileState.Finished(file))
        } finally {
            if (deleteFile) {
                file.delete()
            }
        }
    }

For completness here is how to get root folder in which you are going to save the video

        fun videosRootFolder(context: Context): File {
            return File(
                Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES),
                APP_DIRECTORY_NAME
            ).run {
                if (exists()) {
                    this
                } else {
                    this.mkdir()
                    this
                }
            }
        }
Amr
  • 1,068
  • 12
  • 21
  • Could you please include the ResponseBodyListener definition as that currently seems to be missing. – JamieH Aug 25 '22 at 09:10
  • 2
    @JamieH Yes indeed, i added it at the beginning. you could also just use lambda instead of whole interface. – Amr Aug 25 '22 at 10:33
  • This should be accepted as the right answer, because others answers just showing copying of alredy downloaded file. It's actually amusing that there is no "out of the box" for this simple and obvious case. – Ruslan Feb 02 '23 at 16:54
-4

you can take a look here, you dont have to implement it by yourself ,the idea behind is to take the content-length of the request and when you write on the buffer just calculate your progress

Community
  • 1
  • 1
Lior
  • 832
  • 6
  • 6
  • The link you provided refers to uploading files not downloading, so the implementation is different. – w3bshark Aug 31 '18 at 19:51