Выбор файла из собственного внешнего хранилища Андроид устройства

Эта статья начинает цикл статей о разработке мобильного приложения для напоминания о приёме лекарств — MedAlarm. Приложение разрабатывается для Андроид на языке Котлин в Android Studio. В этой статье исследуем выбор файла из хранилища Андроид.

Идея приложения MedAlarm: пользователь составляет список приёма лекарств в виде таблицы с указанием названия лекарства, графика его приёма, мелодии сигнала; после приёма лекарства в журнале фиксируется время приёма. Успешная работа приложения основана на честности пользователя. Проконтролировать факт приёма лекарств смартфоном пока невозможно.

В нашем приложении будут задействованы специальные модули и классы. Их использование мы разберём в нескольких вспомогательных приложениях, создавая свои классы для повторного использования. В конце мы соберём наше приложение из ранее отработанных классов.

Оглавление

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

Создание проекта ViewSelectedFile

В Androd Studio создаем новый проект ViewSelectedFile на основе шаблона Empty Activity :

рис. 1 Создание проекта

Поскольку мы собираемся работать с файлами из хранилища Андроид нужно добавить соответствующее разрешение в AndroidManifest.xml :

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.android.viewselectedfile">
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
...

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

Разработка макета для отображения элемента списка

Далее нам нужно разработать графическое представление (макеты) нашего приложения. Так как для вывода списка файлов мы будем использовать RecyclerView, то для отображения одного элемента списка нужно создать его макет layout_item.xml. По клику правой кнопки мыши на папке res выберем создание нового Android Resourse File как показано на рис. 2 ниже :

рис. 2 Добавление нового файла ресурсов для макета элемента.

В открывшемся окне настроим поля, как показано на рис. 3 ниже :

рис. 3 Настройка создаваемого xml-фала для макета элемента.

Перейдём в окно редактирования макета. Макет для одного элемента будет состоять из трёх текстовых полей, расположенных друг под другом. Добавим их в графическом редакторе или написанием в окне Code, зададим идентификаторы и некоторые свойства. Ниже представлен код файла layout_item.xml :

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <TextView
        android:id="@+id/textView_name"
        android:textSize="24sp"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Name of file"
        android:textStyle="bold"
        android:textColor="@color/purple_500"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toTopOf="@id/textView_path"/>
    <TextView
        android:id="@+id/textView_path"
        android:textSize="16sp"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Path"
        app:layout_constraintTop_toBottomOf="@id/textView_name"
        app:layout_constraintBottom_toTopOf="@id/textView_size"/>
    <TextView
        android:id="@+id/textView_size"
        android:textSize="16sp"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Size of file in bytes or Directory"
        app:layout_constraintTop_toBottomOf="@id/textView_path"
        app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>

Разработка макета главного окна приложения

Дважды кликнув по файлу activity_main.xml, откроем его в редакторе макетов. Главное окно будет состоять всего из 2 элементов. Используем уже имеющееся TextView для вывода имени выбранного файла и добавим ниже него RecyclerView для вывода списка каталогов и файлов в текущем каталоге. Добавим идентификаторы полей и настройки свойств. Код файла activity_main.xml приведён ниже :

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/selected_file"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/no_info"
        android:textSize="24sp"
        android:layout_margin="4dp"
        app:layout_constraintBottom_toTopOf="@id/recyclerview_listfile"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <androidx.recyclerview.widget.RecyclerView
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:id="@+id/recyclerview_listfile"
        android:layout_margin="2dp"
        tools:listitem="@layout/layout_item"
        app:layout_constraintTop_toBottomOf="@id/selected_file"
        app:layout_constraintBottom_toBottomOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

Создание класса для получения и хранения дерева каталогов и файлов

В Андроид устройствах файлы хранятся в так называемых внутренних и внешних хранилищах. Физически они находятся в памяти устройства и отличаются только возможностью доступа к файлам в них. У каждого приложения есть своё внутреннее хранилище файлов, доступ к которым разрешён только из этого приложения. К файлам из внешнего хранилища могут обращаться все приложения, у которых есть разрешение пользователя для работы с памятью устройства.

Создадим класс для работы с деревом каталогов и файлов MyFileLister.kt. По клику правой кнопки мыши добавим новый класс Котлин с именем MyFileLister как показано на рисунках 4 и 5.

рис. 4 Добавление в проект нового класса Котлин.
рис. 5 Ввод имени и типа File

Для хранения данных, которые будем отображать в RecyclerView для каждого элемента (файл или каталог), создадим класс данных MyFile. В этом классе определим 4 поля для хранения отображаемых данных : 3 поля типа String и 1 поле типа Long. В Котлин реализация такого класса делается очень просто :

data class MyFile(val name: String, val path: String, val size: Long, val type: String) {
}

Имена переменных отражают назначение хранящихся в них данных. Поле type пока будет иметь 2 значения : ‘FILE‘ и ‘DIR’. Далее продолжим разрабатывать класс MyFileLister. В начале объявим переменные для хранения дерева файловой системы, статуса хранилища, выбранного и родительского каталога, три из которых проинициализируем в init методе. Напишем два метода isExternalStorage… для проверки доступности (статуса) внешнего хранилища. Если доступ есть, то проинициализируем объявленные ранее переменные. Возврат списков будем делать проверяя значение переменной status, которое должно стать true. Код класса MyFileLister приведён ниже :

package com.android.viewselectedfile

import android.os.Build
import android.os.Environment
import android.util.Log
import androidx.annotation.RequiresApi
import java.io.File
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.Path

private const val TAG = "VIEWSELECTEDFILE"

class MyFileLister (val path: String = "MyFileStorage") {

    private var status = false
    lateinit var fileTreeWalk: FileTreeWalk
    lateinit var currentDirectory: String
    lateinit var rootStorageDirectory: String

    private val isExternalStorageReadOnly: Boolean get() {
        val extStorageState = Environment.getExternalStorageState()
        return Environment.MEDIA_MOUNTED_READ_ONLY.equals(extStorageState)
    }

    private val isExternalStorageAvailable: Boolean get() {
        val extStorageState = Environment.getExternalStorageState()
        return Environment.MEDIA_MOUNTED.equals(extStorageState)
    }

    init {
        Log.i(TAG, "init")

        if (!isExternalStorageReadOnly || isExternalStorageAvailable) {
            rootStorageDirectory = Environment.getExternalStorageDirectory().toString()
            currentDirectory = Environment.getExternalStorageDirectory().toString()
            Log.i(TAG, "pt => ${Environment.getExternalStorageDirectory().toString()}")

            fileTreeWalk = File(Environment.getExternalStorageDirectory().toString()).walk()

            status = true
            Log.i(TAG, "load fileTreeWalk status=" + status.toString())
        }
    }

    @RequiresApi(Build.VERSION_CODES.O)
    fun getDirectoryFilesList(selPath: String = ""): List<MyFile> {
        val res = emptyList<MyFile>()
        if (status){
            Log.i(TAG, "ftw => " + fileTreeWalk.toString())
            Log.i(TAG, "selPath => " + selPath)
            val tmpListFiles = mutableListOf<MyFile>()

            if (selPath != "") {
                if (selPath == "..") {
                    var i = currentDirectory.length
                    while (i > 0) {
                        i--
                        if (currentDirectory[i] == '/') break
                    }
                    currentDirectory = currentDirectory.substring(0, i)
                }
                else {
                    currentDirectory += "/" + selPath
                }
                Log.i(TAG, "upgate currentDirectory => " + currentDirectory)
                if (currentDirectory != rootStorageDirectory) {
                    val parentDir: MyFile = MyFile("..", currentDirectory, 0, "DIR")
                    tmpListFiles.add(parentDir)
                }
            }

            fileTreeWalk.filter { it.isDirectory }.forEach {
                var onlypath: String = it.path.substring(0, it.path.length - it.name.length - 1)
                var mf = MyFile(it.name, it.path, it.length(), "DIR")
                //Log.i(TAG, "mf => " + mf.toString())
                if (onlypath == currentDirectory)
                tmpListFiles.add(mf)
            }
            fileTreeWalk.filter { it.isFile }.forEach {
                var onlypath: String = it.path.substring(0, it.path.length - it.name.length - 1)
                var mf = MyFile(it.name, it.path, it.length(), "FILE")
                //Log.i(TAG, "mf => " + mf.toString())
                if (onlypath == currentDirectory)
                tmpListFiles.add(mf)
            }
            //Log.i(TAG, "onlypath => " + tmpListFiles.toString())
            return tmpListFiles.toList()
        }
        return res
    }

    @RequiresApi(Build.VERSION_CODES.O)
    fun getFilesList(): List<MyFile> {
        val res = emptyList<MyFile>()
        if (status){
            Log.i(TAG, "ftw => " + fileTreeWalk.toString())
            val tmpListFiles = mutableListOf<MyFile>()
            fileTreeWalk.filter { it.isFile }.forEach {
                var mf = MyFile(it.name, it.path, it.length(), "FILE")
                //Log.i(TAG, "mf => " + mf.toString())
                tmpListFiles.add(mf)
            }
            //Log.i(TAG, "tmpListFiles => " + tmpListFiles.toString())
            return tmpListFiles.toList()
        }
        return res
    }
}

Список файлов и каталогов для выбранного каталога возвращает метод getDirectoryFilesList. В качестве параметра он получает имя выбранного каталога. Метод реализует логику переходов по дереву файловой системы. В возвращаемом списке первым идет родительский каталог, обозначенный как < .. >. Затем идут все внутренние каталоги первого уровня, в которые можно перейти из активного каталога. Тип этих объектов MyFile «DIR». Далее в список во втором цикле forEach добавляются все файлы активного каталога с типом «FILE». Перед проходами циклов корректируем в зависимости от переданного значения selPath выбранного каталога имя активного каталога, используя строковые операции. Если мы попали в родительский каталог и выше двигаться некуда, то две точки в начало списка не добавляем. Метод getFilesList возвращает список всех файлов, находящихся во внешнем хранилище.

Реализация просмотра списка каталогов и файлов с помощью RecyclerView

Для начала добавим зависимость в файл build.gradle :

implementation 'androidx.recyclerview:recyclerview:1.2.1'

Далее в файл MainActivity.kt добавим 2 строки кода для запуска этого элемента :

val recyclerView: RecyclerView = findViewById(R.id.recyclerview_listfile)
recyclerView.layoutManager = LinearLayoutManager(this)

Далее нужно создать специальный адаптер для отображения элементов списка в том количестве, которое ограничено видимой областью и подключить к созданному RecyclerView. Создадим новый Котлин класс с именем MyRecyclerAdapter и напишем код, приведенный ниже :

import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView

class MyRecyclerAdapter(private var files: List<MyFile>) :
    RecyclerView.Adapter<MyRecyclerAdapter.MyViewHolder>() {

    public var onItemClick: ((MyFile) -> Unit)? = null

    inner class MyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView),
        View.OnClickListener {
        val titleTextView: TextView = itemView.findViewById(R.id.textView_name)
        val singerTextView: TextView = itemView.findViewById(R.id.textView_path)
        val sizeOrTypeTextView: TextView = itemView.findViewById(R.id.textView_size)

        init {
            itemView.setOnClickListener {
                onItemClick?.invoke(files[adapterPosition])
            }
        }

        override fun onClick(p0: View?) {
            Toast.makeText(itemView.context, "${titleTextView.text} pressed!", Toast.LENGTH_SHORT).show()
        }
    }
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
        val itemView =
            LayoutInflater.from(parent.context)
                .inflate(R.layout.layout_item, parent, false)
        return MyViewHolder(itemView)
    }

    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
        holder.titleTextView.text = files[position].name
        holder.singerTextView.text = files[position].path
        holder.sizeOrTypeTextView.text = files[position].size.toString() + " bytes"
        if (files[position].type == "DIR") {
            holder.sizeOrTypeTextView.text = "Directory"
            holder.sizeOrTypeTextView.setTextColor(Color.MAGENTA)
        }
        else {
            holder.sizeOrTypeTextView.setTextColor(Color.GREEN)
        }
    }

    override fun getItemCount(): Int {
        return files.size
    }

    public fun updateDataFiles(newFiles: List<MyFile>) {
        this.files = newFiles
        notifyDataSetChanged()
    }
}

Для заполнения полей макета layout_item.xml унаследуем наш класс от RecyclerView.Adapter. В качестве параметра в нем укажем специальный внутренний класс MyViewHolder. Класс MyViewHolder на основе ViewHolder служит для оптимизации ресурсов. Он служит своеобразным контейнером для всех компонентов, которые входят в элемент списка. При этом RecyclerView создаёт ровно столько контейнеров, сколько нужно для отображения на экране. Новый класс добавим в состав нашего созданного ранее класса. В скобках указываем название для элемента списка на основе View и этот же параметр указываем и для RecycleView.ViewHolder. В созданном классе-контейнере создадим переменные для полей макета элемента. Их у нас три.

Андроид студия попросит нас реализовать три метода, заготовки для которых можно получить с помощью генератора кода. В методе onCreateViewHolder нужно указать идентификатор макета для отдельного элемента списка, созданный нами ранее в файле layout_item.xml. А также вернуть наш объект класса ViewHolder. Пусть устройство может отобразить на экране 9 элементов списка. RecyclerView создаст 11-12 элементов (с запасом). Неважно, каким большим будет ваш список, но все данные будут размещаться в этих же 11 элементах, автоматически меняя содержимое при прокрутке. В методе onBindViewHolder заполняем переменные макета данными очередного элемента списка. Для файла выводим его размер, для каталога пишем «Directory». В третьем текстовом поле устанавливаем цвет текста разным для файлов и каталогов для наглядности. В методе getItemCount вернём количество элементов списка, чтобы адаптер знал сколько элементов нужно отображать. Осталось в файле MainActivity.kt установить наш класс MyRecyclerAdapter в параметр adapter переменной recyclerView и передать наш список файлов для отображения :

val recyclerAdapter = MyRecyclerAdapter(mfl.getDirectoryFilesList())
recyclerView.adapter = recyclerAdapter

Давайте скомпилируем и запустим наше приложение. На экране будет выведен список каталогов и файлов корневого каталога внешнего хранилища Андроид устройства. На моём телефоне получилось так :

рис. 6 Скриншот экрана

Реализация обработки нажатий по элементам экрана

Это уже не плохо, но пока бесполезно. Давайте добавим в MainActivity.kt обработку событий кликов (касаний, нажатий) по элементам TextView и RecyclerView. Подготовим все в классе адаптера. Добавим наследование класса inner class MyViewHolder от View.OnClickListener и сделаем его внутренним для обеспечения видимости переменных. Определим тип функции обработки клика :

public var onItemClick: ((MyFile) -> Unit)? = null

и добавим её вызов в блоке init конструктора класса MyViewHolder. Переопределим функцию override fun onClick, как этого требует Андроид студия, и используем её для первоначальной проверки. При клике появляется всплывающее сообщение с именем нажатой ячейки. Далее перейдём в класс MainActivity. Объявим две переменные :

var curSelFile: MyFile = MyFile("file not select", "", 0, "FILE")
val selectFile: TextView = findViewById(R.id.selected_file)

В curSelFile будем хранить данные о выбранном файле. Для работы с нашим полем TextView главного макета будем использовать selectFile. Добавим методы для обработки событий «клика» по RecyclerView и TextView :

recyclerAdapter.onItemClick = { myFile ->
    Log.i(TAG, "pressed ${myFile.name}")
    if (myFile.type == "FILE") {
        selectFile.text = myFile.name
        curSelFile = myFile
    }
    else {
        recyclerAdapter.updateDataFiles(mfl.getDirectoryFilesList(myFile.name))
    }
}

selectFile.setOnClickListener {
    if (curSelFile.name != "file not select") {
        var fileInputStream = FileInputStream(curSelFile.path)
        val bytesArr: ByteArray = ByteArray(200)
        val sz = fileInputStream.read(bytesArr)
        Log.i(TAG, "sz => ${sz}")
        Toast.makeText(this, String(bytesArr), Toast.LENGTH_SHORT).show()
        fileInputStream.close()
    }
}

В recyclerAdapter.onItemClick проверим тип элемента списка. Для файла выведем его имя в текстовое поле selectFile и данные о файле сохраним в переменной curSelFile для возможного использования. Если «клик» был по каталогу, то обновим содержание списка данными для этого каталога. Для перехода по дереву каталогов назад (возврат вверх) в качестве имени каталога используется < .. >, записанные первыми в списке. При клике по текстовому полю с именем выбранного файла во всплывающем окне будет показано содержание первых 200 байт этого файла (или весь, если его длина меньше 200 байт). Скомпилировав и запустив наше приложение на экране моего смартфона получился следующий результат :

рис. 7 Скриншот экрана по клику на выбранном файле.

Для отладки приложения некоторые промежуточные результаты выводились в окно Logcat. В окне ниже выбрано содержимое по тегу «VIEWSELECTEDFILE«.

2022-10-17 15:27:22.039 7718-7718/com.android.viewselectedfile I/VIEWSELECTEDFILE: START
2022-10-17 15:27:22.040 7718-7718/com.android.viewselectedfile I/VIEWSELECTEDFILE: init
2022-10-17 15:27:22.049 7718-7718/com.android.viewselectedfile I/VIEWSELECTEDFILE: pt => /storage/emulated/0
2022-10-17 15:27:22.052 7718-7718/com.android.viewselectedfile I/VIEWSELECTEDFILE: load fileTreeWalk status=true
2022-10-17 15:27:22.053 7718-7718/com.android.viewselectedfile I/VIEWSELECTEDFILE: ftw => kotlin.io.FileTreeWalk@8053d8a
2022-10-17 15:27:22.053 7718-7718/com.android.viewselectedfile I/VIEWSELECTEDFILE: selPath =>
2022-10-17 15:27:28.118 7718-7718/com.android.viewselectedfile I/VIEWSELECTEDFILE: pressed Music
2022-10-17 15:27:28.118 7718-7718/com.android.viewselectedfile I/VIEWSELECTEDFILE: ftw => kotlin.io.FileTreeWalk@8053d8a
2022-10-17 15:27:28.118 7718-7718/com.android.viewselectedfile I/VIEWSELECTEDFILE: selPath => Music
2022-10-17 15:27:28.118 7718-7718/com.android.viewselectedfile I/VIEWSELECTEDFILE: upgate currentDirectory => /storage/emulated/0/Music
2022-10-17 15:27:32.803 7718-7718/com.android.viewselectedfile I/VIEWSELECTEDFILE: pressed моя
2022-10-17 15:27:32.804 7718-7718/com.android.viewselectedfile I/VIEWSELECTEDFILE: ftw => kotlin.io.FileTreeWalk@8053d8a
2022-10-17 15:27:32.805 7718-7718/com.android.viewselectedfile I/VIEWSELECTEDFILE: selPath => моя
2022-10-17 15:27:32.806 7718-7718/com.android.viewselectedfile I/VIEWSELECTEDFILE: upgate currentDirectory => /storage/emulated/0/Music/моя
2022-10-17 15:27:37.682 7718-7718/com.android.viewselectedfile I/VIEWSELECTEDFILE: pressed ..
2022-10-17 15:27:37.682 7718-7718/com.android.viewselectedfile I/VIEWSELECTEDFILE: ftw => kotlin.io.FileTreeWalk@8053d8a
2022-10-17 15:27:37.683 7718-7718/com.android.viewselectedfile I/VIEWSELECTEDFILE: selPath => ..
2022-10-17 15:27:37.683 7718-7718/com.android.viewselectedfile I/VIEWSELECTEDFILE: upgate currentDirectory => /storage/emulated/0/Music
2022-10-17 15:27:41.907 7718-7718/com.android.viewselectedfile I/VIEWSELECTEDFILE: pressed моя
2022-10-17 15:27:41.907 7718-7718/com.android.viewselectedfile I/VIEWSELECTEDFILE: ftw => kotlin.io.FileTreeWalk@8053d8a
2022-10-17 15:27:41.907 7718-7718/com.android.viewselectedfile I/VIEWSELECTEDFILE: selPath => моя
2022-10-17 15:27:41.908 7718-7718/com.android.viewselectedfile I/VIEWSELECTEDFILE: upgate currentDirectory => /storage/emulated/0/Music/моя
2022-10-17 15:27:51.022 7718-7718/com.android.viewselectedfile I/VIEWSELECTEDFILE: pressed proba.txt
2022-10-17 15:27:54.297 7718-7718/com.android.viewselectedfile I/VIEWSELECTEDFILE: sz => 83

В логе отражены моменты формирования списка файлов, перемещения по дереву каталогов и просмотр файла proba.txt, ранее созданного в одном из каталогов.

Итоги

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

  • получение дерева файлов и каталогов внешнего хранилища и перемещение в нём;
  • вывод списка элементов в своём формате, где каждый элемент является пользовательским классом данных, с помощью RecyclerView;
  • чтение части данных из файла (или всего маленького файла) во внешнем хранилище и отображение его во всплывающем окне;
  • создание и настройка двух макетов пользовательского графического интерфейса.

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

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *