Android Studio. Kotlin. Сохранение файла в общедоступной папке Download

В работе над своим проектом больше всего времени я убил на то, чтобы разобраться, как правильно сохранить файл в общедоступную папку, например, Download. Мне не удалось найти четкого и ясного объяснения в интернете. Собирал информацию по частям и доходил до результата методом проб и ошибок.

Виной всей этой сложности — множество факторов. Языковой барьер: русский — английский, Kotlin — Java. Различия в способах копирования в разных версиях Android. Разобраться было не просто. В итоге — пишу этот гайд, чтобы облегчить жизнь тем, кто пойдет за мной следом.

Задача

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

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

Решение

1. Правлю файл Манифеста

Указываю в Манифесте разрешение на доступ к внешнему хранилищу:  


    ...
  
    

    

Я так понял, что этого достаточно. При разрешенном доступе для записи, доступ для чтения можно не получать.

2. Обрабатываю исключение (exeption)

Весь код, отвечающий за копирование, упаковываю в try. Если что-то пойдет не так, то приложение «не вылетит», выдав пользователю сообщение об ошибке, а выполнится код в catch.

try {
  // копирую данные
  ...
  
} catch (e: Exception) {
  e.printStackTrace()
  // если что-то пошло не так
  ...
            
}

3. Проверяю версию Android

Смотрю информацию о версии в файле build.gradle (Module)

android {
    ...

    defaultConfig {
    
        applicationId "имя.вашего.проекта"
        minSdkVersion 23
        targetSdkVersion 30
        
        ...
    }

    ...
}

В моем проекте указано, что целевая версия — 30, минимальная — 23. Это значит, что устройства с такой минимальной версией должны справляться с моим приложением успешно. Для большей ясности о версиях Android можно посмотреть на следующий скриншот:

Нумерация версий Android с их сладкими названиямиНумерация версий Android с их сладкими названиями

В табличке выше смотрю столбец API Level. Android 6.0 Marshmellow — минимальная версия Android, 84,9% устройств будет поддерживаться.

Android 10 в коде имеет маркировку «Q» (Build.VERSION_CODES.Q) Оказывается, что копирование файлов, а именно организация доступа к общедоступным папкам, с этой версии Android и выше выглядит совершенно иначе!

Кусок кода с проверкой версии Android и некоторой лирикой от разработчиков GoogleКусок кода с проверкой версии Android и некоторой лирикой от разработчиков Google

Зачем? Чтобы дать вам почувствовать вкус вашего будущего, предварительный просмотр того, что грядет. Con permiso, Capitan. Зал арендован, оркестр задействован. Пришло время посмотреть, умеешь ли ты танцевать.

Видимо я танцевать научился, иначе бы не писал эту статью. Я понял, что код для копирования файла в общедоступную папку для Android 10+ должен быть другим.

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// version >= 29 (Android 10, 11, ...)

...
                
} else {
// version < 29 (Android ..., 7,8,9)
                
...

}

Засим в обязательном порядке проверяю версию и готовлюсь писать разный код. Печаль…

4. Копирую, если версия Android >= 10 (SdkVersion >= 29)

В этом случае никаких разрешений получать не надо. По-моему, даже в Манифесте можно ничего не указывать. Тут и правда мне пришлось, как у нас говорят — «потанцевать с бубном», пока я не разобрался что к чему.

val contentValues = ContentValues().apply {
  put(MediaStore.MediaColumns.DISPLAY_NAME, dbFileName)
}
val dstUri = applicationContext.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
if (dstUri != null) {
  val src = FileInputStream(applicationContext.getDatabasePath(DbName.DATABASE_NAME))
  val dst = applicationContext.contentResolver.openOutputStream(dstUri)
  src.copyTo(dst!!)
  src.close()
  dst.close()
  backupOk(dbFileName)
} else backupFail()

Здесь я пользуюсь компонентом MediaStore. Его использование похоже на работу с базой данных. Сначала «запись» со всеми ее полями прописывается в contentValues. Я пишу тут только имя файла в колонке DISPLAY_NAME, хотя в примерах видел, что указывают еще размер SIZE и некоторые другие поля. Но у меня работает и так.

Потом при помощи contentResolver вставляю свою запись в таблицу MediaStore.Downloads.EXTERNAL_CONTENT_URI. В итоге получаю путь к файлу в формате Uri и только после этого по нему копирую данные.

О различных способах копирования можно почитать тут:  https://stackoverflow.com/questions/9292954/how-to-make-a-copy-of-a-file-in-android Я несколько из них попробовал. Все рабочие, но короче и лаконичнее, конечно, выглядит src.copyTo(dst)

5. Проверяю, получены ли разрешения

Для Android 9 и ниже мало указать в Манифесте разрешения для доступа. Их еще нужно получить. Доступ к общим папкам приложению может дать пользователь, если его об этом запросить или он сам в настройках указал разрешения.

Настройки -> Приложения → Разрешения» />Настройки → Приложения → Разрешения</p>

<p>Для того, чтобы проверить, есть ли у моего приложения разрешение доступа к хранилищу, пишу следующий код: </p>

<pre><code class=if (ContextCompat.checkSelfPermission(applicationContext, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) { // разрешение есть ... } else { // разрешения нет ... }

Попытки копировать без разрешения не увенчаются успехом.

6. Запрашиваю разрешение, если его нет

Можно попросить пользователя дать разрешение приложению на копирование файлов в общие папки. Выглядеть оно может примерно так:

Предоставить приложению доступ к фотографиям, медиа и файлам на устройстве?Предоставить приложению доступ к фотографиям, медиа и файлам на устройстве?

Код запроса состоит из двух частей. Первая — спрашивает. Вторая — получает ответ (callback).

ActivityCompat.requestPermissions(this,
  arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
  Const.REQUEST_CODE)

Этот нехитрый код покажет пользователю окно с вопросом из скриншота выше и предложит разрешить (allow) или запретить (deny) доступ. Request_code — любое число, с которым в последствии нужно свериться, чтобы убедиться, что пришел ответ именно для вашего приложения.

7. Обрабатываю ответ пользователя

Ответ буду получать в другой части программы при помощи функции onRequestPermissionsResult. Она запускается при нажатии пользователем »Allow» или »Deny».

override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        if (requestCode == Const.REQUEST_CODE && permissions[0] == Manifest.permission.WRITE_EXTERNAL_STORAGE) {
            if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                try {
                    copyFile()
                } catch (e: Exception) {
                    e.printStackTrace()
                    backupFail()
                }

            } else backupFail()
        }
    }

Код выше даю в том виде, как он написан у меня без пропусков. Проверяю, что пришло именно разрешение WRITE_EXTERNAL_STORAGE и requestCode тоже совпадает. Тогда только смотрю результат. Если он — PERMISSION_GRANTED, то копирую, не забыв упаковать копирование в try.

8. Копирую, если версия Android < 10 (SdkVersion < 29)

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

private fun copyFile() {
        val downloadDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
        if (downloadDir.canWrite()) {
            val dbFileName = Const.BACKUP_FILENAME_MASK +
                    fHelper.dateFromLong(applicationContext,
                        fHelper.dateFromToday(),
                        Const.YEAR_MONTH_DAY) + ".db"
            val src = FileInputStream(applicationContext.getDatabasePath(DbName.DATABASE_NAME))
            val dst = FileOutputStream(File(downloadDir, dbFileName))
            src.copyTo(dst)
            src.close()
            dst.close()
            backupOk(dbFileName)
        } else backupFail()
    }

Ответ

Создание копии базы данных работает на всех устройствах Android. Мое приложение не поддерживает версии меньше 6.0 Marshmellow (SdkVersion 23), поэтому на самых ранних версиях я и не проверял.

Очень надеюсь, что мое описание процесса сэкономит вам время, ибо я его в таком полном виде в интернете не встречал.

© Habrahabr.ru