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

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

Задача

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

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

Решение

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

Указываю в Манифесте разрешение на доступ к внешнему хранилищу: <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>

<manifest 
    ...
    package="имя.вашего.проекта">
    ...
  
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>

    <application
        ...
    </application>
</manifest>

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

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 и ниже мало указать в Манифесте разрешения для доступа. Их еще нужно получить. Доступ к общим папкам приложению может дать пользователь, если его об этом запросить или он сам в настройках указал разрешения.

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

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

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<out String>,
        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), поэтому на самых ранних версиях я и не проверял.

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

???? Творите добро, пусть мир будет лучше!

???? Приложение, над которым я работаю скачать можно по ссылке: https://play.google.com/store/apps/details?id=ru.keytomyself.customeraccounting

???? Задавайте вопросы, пишите комментарии. Нужно ли что-нибудь пояснить более подробно?

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


  1. ChPr
    13.01.2022 21:22
    +2

    У меня был такой же кейс, я его решил намного проще через StorageAccessFramework доступный вроде бы с 4.4 версии. Он даже разрешения на запись не требует и работает как обычный файлпикер в десктопах. Пользователь сам сможет выбрать папку и название файла.


    https://developer.android.com/training/data-storage/shared/documents-files#create-file


    1. Andrey_Ananiev Автор
      13.01.2022 21:37

      Спасибо за совет. Обязательно посмотрю и проверю этот способ записи файлов.


    1. zloyreznic
      14.01.2022 10:57

      там тоже не все гладко

      Access restrictions

      On Android 11 (API level 30) and higher, you cannot use the ACTION_OPEN_DOCUMENT_TREE intent action to request access to the following directories:


      1. hdk5
        14.01.2022 20:20
        +1

        На данный момент это можно обойти:

        https://stackoverflow.com/a/66466205


  1. AntoxaM
    14.01.2022 11:34
    +1

    Для большей ясности о версиях Android можно посмотреть на следующий скриншот:

    ух-ты, гугл наконец обновил эту картинку


  1. vasyakolobok77
    14.01.2022 16:36
    +1

    Не примите за занудство, но пара советов по коду:

    1. Потоки стоит закрывать в finally блоке, иначе привет утечка памяти и дескрипторов файлов. В Java есть удобный механизм try-with-resources. В котлине есть https://www.baeldung.com/kotlin/try-with-resources

    2. Логирование ошибок лучше сделать централизовано через библиотеку логирования. Возможно для отслеживания ошибок прикрутить Sentry. Писать e.printStackTrace() уже давно моветон. И в принципе, ошибки стоит отлавливать на том уровне, где вы можете принять решение, что делать дальше - откатиться или показать ошибку пользователю.

    3. Для поддержания чистой архитектуры тут напрашивается шаблон Мост. Выделить интерфейс для работы с папкой Download и для разных версий Андроид использовать свою реализацию этого интерфейса.