Как использовать новый API доступа к SD-карте, представленный для Android 5.0 (Lollipop)?

Фон

В Android 4.4 (KitKat), Google сделал доступ к SD-карте довольно ограниченным.

Начиная с Android Lollipop (5.0), разработчики могут использовать новый API, который просит пользователя подтвердить доступ к определенным папкам, как написано на это сообщение Google-Groups.

Проблема

Сообщение направляет вас посетить два веб-сайта:

Это выглядит как внутренний пример (возможно, позже будет показан в демонстрациях API), но довольно сложно понять, что происходит.

Это официальная документация по новому API, но в нем недостаточно информации о том, как ее использовать.

Вот что он вам говорит:

Если вам действительно нужен полный доступ ко всему поддереву документов, начните с запуска ACTION_OPEN_DOCUMENT_TREE, чтобы пользователь мог выбрать каталог. Затем передайте полученный getData() в fromTreeUri (Context, Uri), чтобы начать работу с выбранным пользователем деревом.

При навигации по дереву экземпляров DocumentFile вы всегда можете использовать getUri(), чтобы получить Uri, представляющий базовый документ для этот объект для использования с openInputStream (Uri) и т.д.

Чтобы упростить код на устройствах, работающих под управлением KITKAT или ранее, вы можете используйте fromFile (Файл), который эмулирует поведение DocumentProvider.

Вопросы

У меня есть несколько вопросов о новом API:

  • Как вы его используете?
  • Согласно сообщению, ОС будет помнить, что приложению было предоставлено разрешение на доступ к файлам/папкам. Как вы проверяете, можете ли вы получить доступ к файлам/папкам? Есть ли функция, которая возвращает мне список файлов/папок, к которым я могу получить доступ?
  • Как вы справляетесь с этой проблемой в Kitkat? Является ли это частью библиотеки поддержки?
  • Есть ли экран настроек в ОС, который показывает, какие приложения имеют доступ к файлам/папкам?
  • Что произойдет, если приложение установлено для нескольких пользователей на одном устройстве?
  • Есть ли другая документация/учебник об этом новом API?
  • Можно ли отозвать разрешения? Если да, то есть ли намерение отправляться в приложение?
  • Будет ли запрос разрешать работу рекурсивно в выбранной папке?
  • Будет ли использование разрешения также давать пользователю возможность множественного выбора по выбору пользователя? Или приложение должно конкретно указывать, какие файлы/папки разрешить?
  • Есть ли способ эмулятора попробовать новый API? Я имею в виду, что у него есть раздел SD-карты, но он работает как основное внешнее хранилище, поэтому весь доступ к нему уже предоставлен (с использованием простого разрешения).
  • Что происходит, когда пользователь заменяет SD-карту другим?

Ответ 1

Множество хороших вопросов, дайте понять.:)

Как вы его используете?

Здесь отличный учебник для взаимодействия с платформой доступа к хранилищу в KitKat:

https://developer.android.com/guide/topics/providers/document-provider.html#client

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

    Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
    startActivityForResult(intent, 42);

Затем в вашем onActivityResult() вы можете передать выбранный пользователем Uri в новый вспомогательный класс DocumentFile. Вот краткий пример, в котором перечислены файлы в выбранном каталоге, а затем создается новый файл:

public void onActivityResult(int requestCode, int resultCode, Intent resultData) {
    if (resultCode == RESULT_OK) {
        Uri treeUri = resultData.getData();
        DocumentFile pickedDir = DocumentFile.fromTreeUri(this, treeUri);

        // List all existing files inside picked directory
        for (DocumentFile file : pickedDir.listFiles()) {
            Log.d(TAG, "Found file " + file.getName() + " with size " + file.length());
        }

        // Create a new file and write into it
        DocumentFile newFile = pickedDir.createFile("text/plain", "My Novel");
        OutputStream out = getContentResolver().openOutputStream(newFile.getUri());
        out.write("A long time ago...".getBytes());
        out.close();
    }
}

Uri, возвращаемый DocumentFile.getUri(), достаточно гибкий, чтобы использовать с различными API-интерфейсами платформы. Например, вы можете поделиться им с помощью Intent.setData() с помощью Intent.FLAG_GRANT_READ_URI_PERMISSION.

Если вы хотите получить доступ к этому Uri из собственного кода, вы можете вызвать ContentResolver.openFileDescriptor(), а затем использовать ParcelFileDescriptor.getFd() или detachFd() для получения традиционного целочисленного дескриптора файла POSIX.

Как вы можете проверить доступ к файлам/папкам?

По умолчанию Uris, возвращаемый с помощью возможностей Storage Access Framework, не сохраняется при перезагрузках. Платформа "предлагает" возможность сохранять разрешение, но вам все равно нужно "взять" разрешение, если вы этого хотите. В нашем примере выше вы бы назвали:

    getContentResolver().takePersistableUriPermission(treeUri,
            Intent.FLAG_GRANT_READ_URI_PERMISSION |
            Intent.FLAG_GRANT_WRITE_URI_PERMISSION);

Вы всегда можете выяснить, какие постоянные гранты для вашего приложения доступны через API ContentResolver.getPersistedUriPermissions(). Если вам больше не нужен доступ к сохраненному Uri, вы можете освободить его с помощью ContentResolver.releasePersistableUriPermission().

Доступно ли это на KitKat?

Нет, мы не можем ретроактивно добавлять новые функции к более старым версиям платформы.

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

В настоящее время нет пользовательского интерфейса, который показывает это, но вы можете найти информацию в разделе "Предоставленные разрешения Uri" вывода adb shell dumpsys activity providers.

Что произойдет, если приложение установлено для нескольких пользователей на одном устройстве?

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

Можно ли отозвать разрешения?

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

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

Будет ли запрос рекурсивно работать в выбранной папке?

Yep, намерение ACTION_OPEN_DOCUMENT_TREE дает вам рекурсивный доступ к существующим и вновь созданным файлам и каталогам.

Предоставляет ли это несколько вариантов?

Да, с KitKat поддерживался множественный выбор, и вы можете разрешить его, установив EXTRA_ALLOW_MULTIPLE при запуске вашего намерения ACTION_OPEN_DOCUMENT. Вы можете использовать Intent.setType() или EXTRA_MIME_TYPES, чтобы сузить типы файлов, которые можно выбрать:

http://developer.android.com/reference/android/content/Intent.html#ACTION_OPEN_DOCUMENT

Есть ли способ эмулятора попробовать новый API?

Да, основное устройство хранения данных должно появиться в сборщике, даже на эмуляторе. Если ваше приложение использует платформу доступа к хранилищу для доступа к разделяемому хранилищу, вам больше не нужны разрешения READ/WRITE_EXTERNAL_STORAGE и вы можете удалить их или использовать функцию android:maxSdkVersion, чтобы запрашивать их только на более ранних версиях платформы.

Что происходит, когда пользователь заменяет SD-карту другим?

Когда задействуются физические носители, UUID (такой как серийный номер FAT) основного носителя всегда записывается в возвращаемый Uri. Система использует это для подключения к мультимедиа, который был выбран пользователем, даже если пользователь меняет носитель между несколькими слотами.

Если пользователь поменяется на второй карте, вам необходимо запросить доступ к новой карте. Поскольку система запоминает гранты на основе UUID, вы будете продолжать иметь ранее предоставленный доступ к исходной карте, если пользователь повторно вставляет ее позже.

http://en.wikipedia.org/wiki/Volume_serial_number

Ответ 2

В моем проекте Android в Github, приведенном ниже, вы можете найти рабочий код, который позволяет писать на extSdCard на Android 5. Предполагается, что пользователь дает доступ ко всей SD-карте, а затем позволяет писать всюду на этой карте. (Если вы хотите иметь доступ только к одиночным файлам, все становится проще.)

Snipplets главного кода

Запуск платформы доступа к хранилищу:

@TargetApi(Build.VERSION_CODES.LOLLIPOP)
private void triggerStorageAccessFramework() {
    Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
    startActivityForResult(intent, REQUEST_CODE_STORAGE_ACCESS);
}

Обработка ответа из платформы доступа к хранилищу:

@TargetApi(Build.VERSION_CODES.LOLLIPOP)
@Override
public final void onActivityResult(final int requestCode, final int resultCode, final Intent resultData) {
    if (requestCode == SettingsFragment.REQUEST_CODE_STORAGE_ACCESS) {
        Uri treeUri = null;
        if (resultCode == Activity.RESULT_OK) {
            // Get Uri from Storage Access Framework.
            treeUri = resultData.getData();

            // Persist URI in shared preference so that you can use it later.
            // Use your own framework here instead of PreferenceUtil.
            PreferenceUtil.setSharedPreferenceUri(R.string.key_internal_uri_extsdcard, treeUri);

            // Persist access permissions.
            final int takeFlags = resultData.getFlags()
                & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
            getActivity().getContentResolver().takePersistableUriPermission(treeUri, takeFlags);
        }
    }
}

Получение выходного потока для файла через платформу доступа к хранилищу (с использованием сохраненного URL-адреса, предполагая, что это URL-адрес корневой папки внешней SD-карты)

DocumentFile targetDocument = getDocumentFile(file, false);
OutputStream outStream = Application.getAppContext().
    getContentResolver().openOutputStream(targetDocument.getUri());

Используются следующие вспомогательные методы:

public static DocumentFile getDocumentFile(final File file, final boolean isDirectory) {
    String baseFolder = getExtSdCardFolder(file);

    if (baseFolder == null) {
        return null;
    }

    String relativePath = null;
    try {
        String fullPath = file.getCanonicalPath();
        relativePath = fullPath.substring(baseFolder.length() + 1);
    }
    catch (IOException e) {
        return null;
    }

    Uri treeUri = PreferenceUtil.getSharedPreferenceUri(R.string.key_internal_uri_extsdcard);

    if (treeUri == null) {
        return null;
    }

    // start with root of SD card and then parse through document tree.
    DocumentFile document = DocumentFile.fromTreeUri(Application.getAppContext(), treeUri);

    String[] parts = relativePath.split("\\/");
    for (int i = 0; i < parts.length; i++) {
        DocumentFile nextDocument = document.findFile(parts[i]);

        if (nextDocument == null) {
            if ((i < parts.length - 1) || isDirectory) {
                nextDocument = document.createDirectory(parts[i]);
            }
            else {
                nextDocument = document.createFile("image", parts[i]);
            }
        }
        document = nextDocument;
    }

    return document;
}

public static String getExtSdCardFolder(final File file) {
    String[] extSdPaths = getExtSdCardPaths();
    try {
        for (int i = 0; i < extSdPaths.length; i++) {
            if (file.getCanonicalPath().startsWith(extSdPaths[i])) {
                return extSdPaths[i];
            }
        }
    }
    catch (IOException e) {
        return null;
    }
    return null;
}

/**
 * Get a list of external SD card paths. (Kitkat or higher.)
 *
 * @return A list of external SD card paths.
 */
@TargetApi(Build.VERSION_CODES.KITKAT)
private static String[] getExtSdCardPaths() {
    List<String> paths = new ArrayList<>();
    for (File file : Application.getAppContext().getExternalFilesDirs("external")) {
        if (file != null && !file.equals(Application.getAppContext().getExternalFilesDir("external"))) {
            int index = file.getAbsolutePath().lastIndexOf("/Android/data");
            if (index < 0) {
                Log.w(Application.TAG, "Unexpected external file dir: " + file.getAbsolutePath());
            }
            else {
                String path = file.getAbsolutePath().substring(0, index);
                try {
                    path = new File(path).getCanonicalPath();
                }
                catch (IOException e) {
                    // Keep non-canonical path.
                }
                paths.add(path);
            }
        }
    }
    return paths.toArray(new String[paths.size()]);
}

 /**
 * Retrieve the application context.
 *
 * @return The (statically stored) application context
 */
public static Context getAppContext() {
    return Application.mApplication.getApplicationContext();
}

Ссылка на полный код

https://github.com/jeisfeld/Augendiagnose/blob/master/AugendiagnoseIdea/augendiagnoseLib/src/main/java/de/jeisfeld/augendiagnoselib/fragments/SettingsFragment.java#L521

и

https://github.com/jeisfeld/Augendiagnose/blob/master/AugendiagnoseIdea/augendiagnoseLib/src/main/java/de/jeisfeld/augendiagnoselib/util/imagefile/FileUtil.java