Пожалуйста, опишите ошибку

Нашли баг? Помогите нам его исправить, заполнив эту форму

Получение пути к карте памяти SD Card на Android: подводные камни


Потом появился Android Lollipop, а вместе с ним и новые фичи для работы с SD-картой. Появился SAF (Storage Acces Framework), появился новый Intent ACTION_OPEN_DOCUMENT_TREE, с помощью которого можно выбрать корень SD-карты и далее использовать в своих нуждах. На самом деле, SAF был еще на KitKat, но без этого Intent’a толку от него было немного, т.к […]

Потом появился Android Lollipop, а вместе с ним и новые фичи для работы с SD-картой. Появился SAF (Storage Acces Framework), появился новый Intent ACTION_OPEN_DOCUMENT_TREE, с помощью которого можно выбрать корень SD-карты и далее использовать в своих нуждах. На самом деле, SAF был еще на KitKat, но без этого Intent’a толку от него было немного, т.к для доступа к файлам на карте нужно было использовать Intent ACTION_OPEN_DOCUMENT, который, судя по названию, дает возможность юзеру выбрать файл, который он хочет редактировать, вручную через системный пикер. Окей, а если у пользователя 5000 файлов, будет ли он это делать? Нет.

Определение внешних накопителей на устройстве

Начну с самой больной темы в работе с карточками памяти. У нас в Android’e есть замечательный метод

File Environment.getExternalStorageDirectory()

Судя по названию, это то что нужно. «External» — переводится как «внешний», верно? Но полагаться на этот метод не стоит. Он может вернуть путь ко внутренней памяти, может к карте, но на каждом устройстве происходит по-разному. Такая ситуация сложилась во многом благодаря разным производителям телефонов и их модифицированным прошивкам и оболочкам. Я хочу сказать, что метод getExternalStorage() может возвращать путь не к реальной SD-карте, а к той, которую производитель считает внешним накопителем. Здесь возникает путаница в определениях. Внешний накопитель это не обязательно флэшка: на некоторых девайсах это внутренняя память, на некоторых действительно SD-карта. Точки монтирования карты могут быть любые:

  • /storage/extSdCard/
  • /storage/sdcard1/
  • /storage/sdcard0/
  • /mnt/external_sd/
  • /mnt/external/
  • /mnt/sdcard2/
  • /storage/sdcard0/external_sdcard/
  • /storage/removable/sdcard1/
  • /removable/microsd
  • /mnt/media_rw/sdcard1
  • /mnt/emmc

И это не является какой-то большой проблемой, пока не столкнешься с ней сам.

На StackOverflow есть множество тем, в которых куча самых разных вариантов определения флешки, начиная от перебора всех возможных комбинаций точек монтирования, попыток получения джавовых переменных среды System.getenv("EXTERNAL_STORAGE"), System.getenv("SECONDARY_STORAGE") и заканчивая парсингом системного файла /system/etc/vold.fstab. Эти способы работают, но каждый только в каком-то частном случае. Ни один способ не покрывает все устройства, либо не работает совсем. И если кажется, что все накопители определяются верно, то всегда найдется какой-нибудь девайс, карта на котором не определяется этими методами. Я перепробовал почти все методы.

Чтобы понять масштаб проблемы, скажу, что я разбирался с ней около четырех месяцев. Эта проблема существует примерно с 2011 года, но до сих пор нет точного решения. Какое то время я довольствовался вот этим более-менее рабочим кодом:

ArrayList<String> allPaths = new ArrayList<>();
ArrayList<String> sdPaths = new ArrayList<>();
for (File file : mContext.getExternalFilesDirs("external")) {
    if (file == null) {
        continue;
    }
    int index = file.getAbsolutePath().lastIndexOf("/Android/data");
    if (index > 0) {
        String path = file.getAbsolutePath().substring(0, index);
        try {
            path = new File(path).getCanonicalPath();
        } catch (Exception e) {
           e.printStackTrace();
        }
        allPaths.add(path);
        if (!file.equals(mContext.getExternalFilesDir("external"))) {
            sdPaths.add(path);
    }
}

Но и он давал сбои на некоторых устройствах. И в очередной раз, получив единицу в маркете, я отправился на поиски.

Мне не давал покоя системный андроидовский пикер (по совместительству файловый менеджер), в котором все накопители всегда верно определяются. Недолго думая, я вытащил системный apk с помощью Root’a и декомпилировал его.

Озарение

Я обнаружил, что пикер использует классы StorageVolume и StorageManager. Он проходит по всем элементам StorageVolume, полученным с помощью метода StorageManger.getVolumeList() и для каждого вызывает методы StorageVolume.getPath() и StorageVolume.getState(). Загвоздка в том, что эти методы скрыты. Они не private, но помечены аннотацией @hide. Ну что поделать, достаем свою рефлексию:

StorageManager getStorageManager() {
    return (StorageManager) mContext.getSystemService(Context.STORAGE_SERVICE);
}
/*
       Use reflection for detecting all storages as android do it
       probably doesn't work with USB-OTG
       works only on API 19+
 */
public List<String> getAllPaths() {
    List<String> allPaths = new ArrayList<>();
    try {
        Class<?> storageVolumeClass = Class.forName("android.os.storage.StorageVolume");
        Method getVolumeList = getStorageManager().getClass().getMethod("getVolumeList");
        Method getPath = storageVolumeClass.getMethod("getPath");
        Method getState = storageVolumeClass.getMethod("getState");
        Object getVolumeResult = getVolumeList.invoke(getStorageManager());
        final int length = Array.getLength(getVolumeResult);

        for (int i = 0; i < length; ++i) {
            Object storageVolumeElem = Array.get(getVolumeResult, i);
            String mountStatus = (String) getState.invoke(storageVolumeElem);
            if (mountStatus != null && mountStatus.equals("mounted")) {
                String path = (String) getPath.invoke(storageVolumeElem);
                if (path != null) {
                    allPaths.add(path);
                }
            }
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
    return allPaths;
}

Возникает вопрос, почему нет никакого официального способа сделать это? Да, использовать рефлексию в боевом проекте не совсем надежно, но других вариантов нет: если система сама использует этот код, то он работает во всех случаях. Практика это подтверждает.

SAF (Storage Access Framework)

Официальная документация:

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

<

p>SAF агрегирует поставщиков контента (Подклассы класса DocumentProvider). Это, например, Google Диск, различные галереи и файловые менеджеры.

SAF выдает URI документов (файлов), которые обладают правами на запись, либо чтение. Можно сказать, что это такой слой над доступом к файлам. Сам по себе класс File ничего не знает о SAF.

<

p>Для того, чтобы получить возможность редактировать данные на карте, требуется получить URI корня SD-карты, который будет обладать правами на редактирование. Далее с помощью этого URI можно будет получить URI любого файла на карте памяти. Для этого нужно запустить системный пикер с помощью Intent ACTION_OPEN_DOCUMENT_TREE и попросить пользователя выбрать корень SD-карты (иначе ничего не получится!).

В картинках:

В коде:

@TargetApi(Build.VERSION_CODES.LOLLIPOP)
    void showDocumentTreeDialog() {
        Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
        startActivityForResult(Intent.createChooser(intent,
        getString(R.string.permission_intent)), REQUEST_CODE_SD_CARD);
    }

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

takePersistableUriPermission(Uri uri, int modeFlags)

c флагами на запись и чтение. После всех этих махинаций необходимо сохранить куда-нибудь полученный Uri SD-карты для дальнейшей работы с ним, например, в SharedPreferences.

@RequiresApi(api = Build.VERSION_CODES.KITKAT)
    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        if (requestCode == REQUEST_CODE_SD_CARD && resultCode == RESULT_OK
                && takePermission(getApplicationContext(), data.getData())) {
           //do your stuff
        }
    }
    
    @RequiresApi(api = Build.VERSION_CODES.KITKAT)
    private boolean takePermission(Context context, Uri treeUri) {
        /*
            Было бы полезно добавить проверку на то, что пришедший URI это URI карты.
            Оставлю эту задачу как упражнение читателям
        */
        try {
            if (treeUri == null) {
                return false;
            }
            context.getContentResolver().takePersistableUriPermission(treeUri,
                    Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION);
            sharedPreferences.putString(SD_CARD_URI,treeUri.toString());
            return true;
        } catch (Exception e2) {
            e2.printStackTrace();
            return false;
        }
    }

Взаимодействие с SD-картой

Читатель наверное уже догадывается, что по-нормальному взаимодействовать с файлами (вроде file.renameTo(file2)) мы не сможем. Если мы посмотрим в код метода file.renameTo(File), то не увидим ничего подозрительного, никаких проверок. И правильно, потому что проверки находятся на уровне файловой системы. И если привычными средствами попробовать изменить файл, находящийся на SD-карте, то получим следующее исключение:

java.io.IOException: Cannot make changes to file your_file.ext

Вот интересный способ определить — можно ли изменять файл:

public boolean isFileWritable(File file) {
        boolean writable;
        try {
            new FileOutputStream(file, true).close();
            writable = file.exists() && file.canWrite();
        } catch (IOException e) {
            writable = false;
        }
        return writable;
    }

Если файл на карте, мы получим IOException.

Чтобы изменить файл на карте памяти, нам нужно получить DocumentFile, представляющий наш файл, но с правами записи, которые мы получили с помощью SAF.

В начале статьи я говорил о двух интентах для SAF: ACTION_OPEN_DOCUMENT_TREE и ACTION_OPEN_DOCUMENT. И я говорил, что мы не будем использовать второй интент, так как это принуждает юзера искать файл вручную. Но у нас есть URI, который мы получили с помощью первого интента и это значит… Нет, никакого стандартного API для получения DocumentFile нет, все ручками.

Алгоритм такой:

  1. У нас есть File
  2. Определяем имя накопителя, на котором находится этот файл
  3. Определяем путь файла относительно накопителя на котором он находится. Получаем строчку вида android/folder/file.txt
  4. Разделяем строчку символом «/»
  5. В цикле для каждой полученной части находим DocumentFile, представляющий этот путь, на основе DocumentFile для предыдущей части
  6. Если алгоритм завершился без ошибок, имеем на выходе DocumentFile, представляющий наш файл

Код:

public DocumentFile getDocumentFile(File file) {
        DocumentFile document = null;
        String baseFolder = null;
        for (String path : getAllPaths()) {
            File filePath = new File(path);
            if (filePath.getAbsolutePath().startsWith(file.getAbsolutePath())) {
                baseFolder = filePath.getAbsolutePath();
                break;
            }
        }
        if (baseFolder == null) {
            return null;
        }
        try {
            String relativePath = file.getCanonicalPath().substring(baseFolder.length() + 1);
            Uri permissionUri = Uri.parse(sharedPreferences.getString(SD_CARD_URI));
            document = getDocumentFileForUri(permissionUri, relativePath);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return document;
    }
    
    /*
        Метод для получения DocumentFile (шаги 4-6)
    */
    private DocumentFile getDocumentFileForUri(Uri treeUri, String relativePath) {
        String[] parts = relativePath.split("/");
        if (parts.length == 0) {
            return null;
        }
        DocumentFile document = DocumentFile.fromTreeUri(mContext, treeUri);
        for (String part : parts) {
            DocumentFile nextDocument = document.findFile(part);
            if (nextDocument != null) {
                document = nextDocument;
            }
        }
        return document;
    }

Далее для редактирования файла можно безболезненно получить поток:

FileOutputStream outputStream = (FileOutputStream) mContentResolver.openOutputStream(documentFile.getUri());

Пример копирования файла класса File в DocumentFile:

public void copyFile(File sourceFile, DocumentFile document) {
        FileInputStream inputStream = null;
        FileOutputStream outputStream = null;
        try {
            inputStream = new FileInputStream(sourceFile);
            outputStream = (FileOutputStream) mContentResolver.openOutputStream(document.getUri());

            FileChannel fileChannelIn = inputStream.getChannel();
            FileChannel fileChannelOut = outputStream.getChannel();
            fileChannelIn.transferTo(0, fileChannelIn.size(), fileChannelOut);
            //noinspection ResultOfMethodCallIgnored
            sourceFile.delete();
        } catch (IOException e) {
            e.printStackTrace();
            //noinspection ResultOfMethodCallIgnored
            sourceFile.delete();
        } finally {
            try {
                if (inputStream != null) inputStream.close();
                if (outputStream != null) outputStream.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

И еще хочу сказать, что теперь код наполнится таким проверками

public void writeFile(File file) {
        boolean fileWritable = isFileWritable(file);
        boolean hasSdCardUri = !sharedPreferences.getString(SD_CARD_URI).isEmpty();
        if (fileWritable || hasSdCardUri) {
            /*
                можно редактировать файл обычно, либо SAF
             */
            return;
        }
        if (Build.VERSION.SDK_INT >= 21) {
                /*
                    добро пожаловать! (запрашиваем у юзера разрешение)
                 */
            throw new NoLollipopWritePermissionException();
            `
        } else if (Build.VERSION.SDK_INT == 19) {
                /*
                    до свидания! (не можем редактировать)
                 */
            throw new NoKitkatWritePermissionException();
        }
    }

Заключение

Надеюсь, что мой опыт, описанный в статье, поможет тем, кто еще не работал с SD-картой, избежать долгих исследований и поиска.

Читать и комментировать

Краснодар

Коммунаров, 268,
3 эт, офисы 705, 707

+7 (861) 200 27 34

Хьюстон

3523 Brinton trails Ln Katy

+1 833 933 0204

Москва

+7 (495) 145-01-05