Не так давно столкнулся с задачей по отображению прогресс бара при отправке файла. Начал искать информацию по данной теме и понял, что ничего толкового на русском языке нет. Подумал-подумал и решил написать свою статью о способах отслеживания прогресса при загрузке и отправке файлов.
Для отправки есть один точный вариант, который работает как часы. Основная задача — переопределить класс okhttp3.RequestBody.
Для получения прогресса во время загрузки есть такой же простой вариант, аннотация @Streaming. Eсть еще один вариант, но он более продвинутый и построен на встроенном DownloadManager.
Отправка файла с получением прогресса
Как говорилось ранее, основная задача — это сделать собственный класс RequestBody, назовем его ProgressRequestBody и унаследуем от RequestBody.
class ProgressRequestBody(): RequestBody() { override fun contentType(): MediaType? { TODO("Not yet implemented") } override fun writeTo(sink: BufferedSink) { TODO("Not yet implemented") } }
В contentType мы просто должны вернуть MediaType, который можно передать в конструктор нашего класса, а перед этим достать его из URI с помощь ContentResolver.getType()
context.contentResolver?.getType(URI)?.toMediaType()
В функцииwriteTo нам необходимо записать в sink наш файл частями, размер которых вы определяете сами. Я возьму DEFAULT_BUFFER_SIZE, который равен бит. В это же время нужно добавить коллбек, который будет возвращать уже записанное число бит.
Итоговый код ProgressRequestBody получился таким:
class ProgressRequestBody( private val file: File, private val contentType: MediaType, private val callback: (Long, Long) -> Unit ): RequestBody() { override fun contentType() = contentType override fun writeTo(sink: BufferedSink) { val length = file.length() val buffer = ByteArray(DEFAULT_BUFFER_SIZE) val fileInputStream = FileInputStream(file) var uploaded = 0L fileInputStream.use { inputStream -> var read:Int while (inputStream.read(buffer).also { read = it } != -1) { uploaded += read.toLong() callback(length, uploaded) sink.write(buffer, 0, read) } } } }
Функция inputStream.read(buffer) возвращает количество байт записанных в buffer, если данных для чтение не осталось, то функция возвращает -1
В функцию интерфейса сервиса необходимо добавить аннотацию @Multipart, а параметром передать файл с аннотацией @Part и типом MultipartBody.Part
interface ApiService { @Multipart @POST("URL") suspend fun uploadFile( @Part file: MultipartBody.Part ): Response<ResponseBody> }
Функция для отправки файла примет следующий вид:
fun uploadFile(file: File, api: ApiService, contentType: MediaType) { viewModelScope.launch { val requestBody = ProgressRequestBody( file = file, contentType = contentType ) { totalSize, uploaded -> TODO("Добавить обработку полученных данных") } val multipartData = MultipartBody.Part.create(requestBody) api.uploadFile(multipartData) } }
В итоге один простой класс позволяет реализовать отслеживание статуса отправки, который можно показать пользователю.
Загрузка файла с получением прогресса, используя DownloadManager
Самый подходящий вариант для загрузки файлов - это использование DownloadManager.
DownloadManager — системный сервис, который обрабатывает длительные загрузки по протоколу HTTP. Этот сервис заслуживает отдельной статьи, так как имеет множество настроек и методов для работы с загрузкой.
Ниже приведен пример, на сколько просто можно загрузить файл с его помощью:
private val downloadManager by lazy { requireContext().getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager } ... val downloadRequest = DownloadManager.Request(Uri.parse(URL)) .setTitle("My Dowload") .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE) .setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, FILENAME) downloadManager.enqueue(downloadRequest)
Для базовой загрузки этого достаточно. Удобно, не правда ли?
Последней строкой запрос добавляется в очередь системного загрузчика. После этого должно появиться уведомление о начале загрузке, в котором будет отображаться прогресс загрузки + кнопка отмены.
Для нашего случая этого не достаточно, прогресс, конечно, отображается, но нам необходимо показывать его в нашем приложении. Для этого сделаем собственный ContentObserver и зарегистрируем его.
val contentProviderObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { override fun onChange(selfChange: Boolean, uri: Uri?, flags: Int) { TODO("Добавить обработку полученных данных") } }
Метод onChange() срабатывает, когда содержимое по данному uri изменяется. В этот момент необходимо получить информацию о загрузке из DownloadManager.
Для таких случаев у менеджера есть метод DownloadManager.query(Query query), инстанс которого можно получить с помощью метода setFilterById():
val query = DownloadManager.Query().setFilterById(downloadId)
В то же время downloadId можно достать из уже знакомого метода enqueue():
val downloadId = downloadManager.enqueue(downloadRequest)
Метод DownloadManager.query(Query query) возвращает cursor, из которого мы и будем доставать необходимую информацию.
Далее получаем значения количества загруженных байтов и размер целого файла:
val downloadBytesColumnIndex = cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR) val totalBytesColumnIndex = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES) cursor.moveToFirst() val curr = cursor.getInt(downloadBytesColumnIndex) val total = cursor.getInt(totalBytesColumnIndex)
И остается только отобразить пользователю информацию, но сделать это нужно только после того, как totalбудет неравен -1 (когда данные действительно появятся):
if (total!= -1) { TODO("Добавить обработку полученных данных") }
В итоге получаем ContentObserver следующего вида:
val contentProviderObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { override fun onChange(selfChange: Boolean, uri: Uri?, flags: Int) { downloadManager.query(query).use { cursor -> val downloadBytesColumnIndex = cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR) val totalBytesColumnIndex = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES) cursor.moveToFirst() val curr = cursor.getInt(downloadBytesColumnIndex) val total = cursor.getInt(totalBytesColumnIndex) if (total != -1) { TODO("Добавить обработку полученных данных") } } } }
Для регистрации ContentObserver нам нужен URI места, куда загружается файл. Его можно получить все из того же DownloadManager.query(Query query) следующим образом:
cursor.moveToFirst() val index = cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI) val localUri = cursor.getString(index) //*
Будьте внимательны, при установке setDestinationInExternalPublicDir или setDestinationInExternalFilesDir localUri будет возвращать null. Потому при установке собсвенного пути не забывайте запоминать URI.
И последний штрих — это регистрация ContentObserver:
contentResolver.registerContentObserver(Uri.parse(localUri), false, contentProviderObserver)
Все заворачиваем в функцию и получаем:
private fun createDownload(url: String) { val downloadRequest = DownloadManager.Request(Uri.parse(link)) .setTitle("My Dowload") .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE) val downloadId = downloadManager.enqueue(downloadRequest) val query = DownloadManager.Query().setFilterById(downloadId) val contentProviderObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { override fun onChange(selfChange: Boolean, uri: Uri?, flags: Int) { downloadManager.query(query).use { cursor -> val downloadBytesColumnIndex = cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR) val totalBytesColumnIndex = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES) cursor.moveToFirst() val curr = cursor.getInt(downloadBytesColumnIndex) val total = cursor.getInt(totalBytesColumnIndex) if (total != -1) { TODO("Добавить обработку полученных данных") } } } } downloadManager.query(query).use { it.moveToFirst() val index = it.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI) val localUri = it.getString(index) context.contentResolver.registerContentObserver(Uri.parse(localUri), false, contentProviderObserver) } }
P.S. Если вы хотите скрыть уведомление от системного загрузчика, то можете установить флаг VISIBILITY_HIDDEN в .setNotificationVisibility, но для этого необходимо добавить пермишен в манифест:
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
Загрузка файла с получением прогресса, используя аннотацию Retrofit’a
Переходим ко второму варианту, который выглядит просто только на первый взгляд. Сразу после того, как вы решите сделать правильную и безопасную реализацию, вы столкнетесь с большим количеством вопросов: “А что будет после закрытия приложения? Что будет, если перейти на другой фрагмент?”
Для правильного функционирования нужно будет написать кучу кода и воспользоваться WorkManager’ом для работы в фоне (но и тут есть нюансы, т.к WorkManager не дает гарантии того, что ваша работа будет запущена в тот же момент, когда вы нажали на кнопку “Загрузить”).
Для реализации этого варианта потребуется аннотация @Streaming. Она позволяет обрабатывать ответ без преобразования тела в byte[], поэтому у нас есть возможность оперировать скачиваемым потоком данных так, как нам это необходимо.
Остется добавить ее к фунции сервиса:
interface ApiService { @Streaming @GET suspend fun downloadFile( @Url url: String ): ResponseBody }
И добавить функцию, которая во время считывания данных будет рассчитывать прогресс загрузки и отправлять его UI:
fun downloadFileWithRetrofit() { viewModelScope.launch { val response = api.downloadFile() response.byteStream().use { inputStream -> val buffer = ByteArray(DEFAULT_BUFFER_SIZE) var progressBytes = 0L val totalSize = response.contentLength() var bytes = inputStream.read(buffer) while (bytes >= 0) { progressBytes += bytes val percent = ((progressBytes * 100 /totalSize)) bytes = inputStream.read(buffer) _progress.emit(percent.toInt()) } } } }
И все готово. Мы получили проценты, а затем отобразили их пользователю.
P.S. Не забудьте сохранить скачанный файл
P.P.S Надеюсь, статья была полезной. Буду благодарен за обратную связь!
