Не так давно столкнулся с задачей по отображению прогресс бара при отправке файла. Начал искать информацию по данной теме и понял, что ничего толкового на русском языке нет. Подумал-подумал и решил написать свою статью о способах отслеживания прогресса при загрузке и отправке файлов.

Для отправки есть один точный вариант, который работает как часы. Основная задача — переопределить класс 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, который равен 8 * 1024 бит. В это же время нужно добавить коллбек, который будет возвращать уже записанное число бит.

Итоговый код  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 Надеюсь, статья была полезной. Буду благодарен за обратную связь!

Комментарии (2)


  1. Rusrst
    17.05.2023 13:57

    На английском информации же навалом по этой теме. GitHub тоже никто не отменял пока...


    1. alexannu Автор
      17.05.2023 13:57

      Согласен, на английском достаточно информации) 

      Но считаю, что на русском статьи тоже нужны — можно упрощать другим разработчикам поиск информации.