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

118

Задний план

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

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

Эта проблема

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

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

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

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

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

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

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

Вопросы

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

  1. Как вы на самом деле это используете?
  2. Согласно сообщению, ОС будет помнить, что приложению было предоставлено разрешение на доступ к файлам / папкам. Как проверить, есть ли у вас доступ к файлам / папкам? Есть ли функция, которая возвращает мне список файлов / папок, к которым я могу получить доступ?
  3. Как вы справляетесь с этой проблемой на Kitkat? Это часть библиотеки поддержки?
  4. Есть ли в ОС экран настроек, показывающий, какие приложения имеют доступ к каким файлам / папкам?
  5. Что произойдет, если приложение установлено для нескольких пользователей на одном устройстве?
  6. Есть ли еще какая-либо документация / руководство по этому новому API?
  7. Можно ли отозвать разрешения? Если да, то отправляется ли в приложение намерение?
  8. Будет ли запрос разрешения работать рекурсивно для выбранной папки?
  9. Может ли использование разрешения дать пользователю возможность множественного выбора по выбору пользователя? Или приложению нужно специально указывать намерение, какие файлы / папки разрешить?
  10. Есть ли способ на эмуляторе попробовать новый API? Я имею в виду, что у него есть раздел SD-карты, но он работает как основное внешнее хранилище, поэтому весь доступ к нему уже предоставлен (с использованием простого разрешения).
  11. Что происходит, когда пользователь заменяет SD-карту другой?
разработчик Android
источник
FWIW, AndroidPolice опубликовал сегодня небольшую статью об этом.
fattire
@fattire Спасибо. но они показывают то, что я уже читал. Но это хорошие новости.
разработчик Android
32
Каждый раз, когда выходит новая ОС, они усложняют нашу жизнь ...
Phantômaxx
@Funkystein правда. Жаль, что они не сделали это на Киткате. Теперь есть 3 типа поведения.
разработчик Android
1
@Funkystein Я не знаю. Давно пользовался. Думаю, сделать эту проверку не так уж и плохо. Вы должны помнить, что они тоже люди, чтобы они могли делать ошибки и время от времени менять свое мнение ...
разработчик Android

Ответы:

143

Много хороших вопросов, давайте углубимся. :)

Как Вы этим пользуетесь?

Вот отличный учебник по взаимодействию с Storage Access Framework в 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);

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

Это доступно на KitKat?

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

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

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

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

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

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

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

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

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

Да, 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?

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

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

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

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

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

Джефф Шарки
источник
2
Понимаю. поэтому было принято решение вместо того, чтобы добавлять больше к известному API (File), использовать другой. ХОРОШО. Не могли бы вы ответить на другие вопросы (написанные в первом комментарии)? Кстати, спасибо, что ответили на все это.
разработчик Android
7
@JeffSharkey Любой способ предоставить OPEN_DOCUMENT_TREE подсказку о начальном местоположении? Пользователи не всегда лучше всех ориентируются в том, к чему приложению требуется доступ.
Джастин
2
Есть ли способ изменить отметку времени последнего изменения ( метод setLastModified () в классе File)? Это действительно важно для таких приложений, как архиваторы.
Quark
1
Допустим, у вас есть сохраненный документ папки Uri, и вы хотите добавить файлы в список позже, в какой-то момент после перезагрузки устройства. DocumentFile.fromTreeUri всегда перечисляет корневые файлы, независимо от того, какой Uri вы ему даете (даже дочерний Uri), поэтому как вы создаете DocumentFile, вы можете вызывать файлы списка, где DocumentFile не представляет ни корневой, ни SingleDocument.
AndersC 03
1
@JeffSharkey Как можно использовать этот URI в MediaMuxer, поскольку он принимает строку в качестве пути к выходному файлу? MediaMuxer (java.lang.String, int
Petrakeas,
45

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

Фрагменты основного кода

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

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

Обработка ответа от Storage Access Framework:

@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);
        }
    }
}

Получение outputStream для файла через Storage Access Framework (с использованием сохраненного 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

Йорг Айсфельд
источник
1
Не могли бы вы поместить его в свернутый проект, который обрабатывает только SD-карту? Кроме того, знаете ли вы, как я могу проверить, все ли доступные внешние хранилища также доступны, чтобы я не запрашивал у них разрешение просто так?
разработчик Android
1
Хотел бы я проголосовать за это больше. Я был на полпути к этому решению и обнаружил, что навигация по документу настолько ужасна, что я подумал, что делаю это неправильно. Хорошо получить подтверждение этому. Спасибо, Google ... зря.
Энтони
1
Да, для записи на внешнюю SD вы больше не можете использовать обычный файловый подход. С другой стороны, для более старых версий Android и для основной SD-карты File по-прежнему является наиболее эффективным подходом. Поэтому для доступа к файлам следует использовать специальный служебный класс.
Йорг Айсфельд
15
@ JörgEisfeld: У меня есть приложение, которое используется File254 раза. Вы можете себе представить, как это исправить ?? Android становится кошмаром для разработчиков из-за полного отсутствия обратной совместимости. Я до сих пор не нашел места, где бы объяснили, почему Google принял все эти глупые решения относительно внешнего хранилища. Некоторые заявляют о «безопасности», но это, конечно, ерунда, поскольку любое приложение может испортить внутреннюю память. Я предполагаю, что попытаться заставить нас использовать их облачные сервисы. К счастью, рутирование решает проблемы ... по крайней мере, для Android <6 ....
Луис А. Флорит
1
ХОРОШО. Волшебным образом после того, как я перезапустил свой телефон, он работает :)
sancho21
0

Это просто дополнительный ответ.

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

/**
 * Get {@link DocumentFile} object from SD card.
 * @param directory SD card ID followed by directory name, for example {@code 6881-2249:Download/Archive},
 *                 where ID for SD card is {@code 6881-2249}
 * @param fileName for example {@code intel_haxm.zip}
 * @return <code>null</code> if does not exist
 */
public static DocumentFile getExternalFile(Context context, String directory, String fileName){
    Uri uri = Uri.parse("content://com.android.externalstorage.documents/tree/" + directory);
    DocumentFile parent = DocumentFile.fromTreeUri(context, uri);
    return parent != null ? parent.findFile(fileName) : null;
}

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    if (requestCode == SettingsFragment.REQUEST_CODE_STORAGE_ACCESS && resultCode == RESULT_OK) {
        int takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION;
        getContentResolver().takePersistableUriPermission(data.getData(), takeFlags);
        String sdcard = data.getDataString().replace("content://com.android.externalstorage.documents/tree/", "");
        try {
            sdcard = URLDecoder.decode(sdcard, "ISO-8859-1");
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        // for example, sdcardId results "6312-2234"
        String sdcardId = sdcard.substring(0, sdcard.indexOf(':'));
        // save to preferences if you want to use it later
        SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this);
        preferences.edit().putString("sdcard", sdcardId).apply();
    }
}
Ангграюди H
источник