Como usar a nova API de acesso por cartão SD apresentada para Android 5.0 (Lollipop)?

118

fundo

No Android 4.4 (KitKat), o Google tornou o acesso ao cartão SD bastante restrito.

A partir do Android Lollipop (5.0), os desenvolvedores podem usar uma nova API que pede ao usuário uma confirmação para permitir o acesso a pastas específicas, conforme escrito nesta postagem do Google-Groups .

O problema

A postagem direciona você a visitar dois sites:

Isso parece um exemplo interno (talvez a ser mostrado nas demonstrações da API mais tarde), mas é muito difícil entender o que está acontecendo.

Esta é a documentação oficial da nova API, mas não fornece detalhes suficientes sobre como usá-la.

Aqui está o que ele diz a você:

Se você realmente precisa de acesso total a uma subárvore inteira de documentos, comece lançando ACTION_OPEN_DOCUMENT_TREE para permitir que o usuário escolha um diretório. Em seguida, passe o getData () resultante para fromTreeUri (Context, Uri) para começar a trabalhar com a árvore selecionada pelo usuário.

Conforme você navega na árvore de instâncias de DocumentFile, você sempre pode usar getUri () para obter o Uri que representa o documento subjacente para aquele objeto, para uso com openInputStream (Uri), etc.

Para simplificar seu código em dispositivos que executam KITKAT ou anterior, você pode usar fromFile (File) que emula o comportamento de um DocumentsProvider.

As questões

Tenho algumas perguntas sobre a nova API:

  1. Como você realmente usa isso?
  2. De acordo com a postagem, o SO vai lembrar que o aplicativo recebeu permissão para acessar os arquivos / pastas. Como você verifica se consegue acessar os arquivos / pastas? Existe uma função que me retorna a lista de arquivos / pastas que posso acessar?
  3. Como você lida com esse problema no Kitkat? Faz parte da biblioteca de suporte?
  4. Existe uma tela de configurações no sistema operacional que mostra quais aplicativos têm acesso a quais arquivos / pastas?
  5. O que acontece se um aplicativo for instalado para vários usuários no mesmo dispositivo?
  6. Existe alguma outra documentação / tutorial sobre esta nova API?
  7. As permissões podem ser revogadas? Em caso afirmativo, há um intent sendo enviado ao aplicativo?
  8. Solicitar a permissão funcionaria recursivamente em uma pasta selecionada?
  9. Usar a permissão também permitiria ao usuário a chance de seleção múltipla por escolha do usuário? Ou o aplicativo precisa informar especificamente a intenção quais arquivos / pastas permitir?
  10. Existe uma maneira no emulador de experimentar a nova API? Quer dizer, ele tem partição de cartão SD, mas funciona como o armazenamento externo primário, então todo o acesso a ele já é concedido (usando uma permissão simples).
  11. O que acontece quando o usuário substitui o cartão SD por outro?
desenvolvedor android
fonte
FWIW, AndroidPolice publicou um pequeno artigo sobre isso hoje.
fattire
@fattire Obrigado. mas eles estão mostrando o que eu já li. No entanto, são boas notícias.
desenvolvedor Android
32
Cada vez que um novo sistema operacional é lançado, eles tornam nossa vida mais complicada ...
Phantômaxx
@Funkystein true. Gostaria que tivessem feito isso em Kitkat. Agora, existem 3 tipos de comportamentos a serem tratados.
desenvolvedor Android de
1
@Funkystein não sei. Eu usei há muito tempo. Não é tão ruim fazer essa verificação, eu acho. Você tem que lembrar que eles também são humanos, então eles podem cometer erros e mudar de ideia de vez em quando ...
desenvolvedor do Android

Respostas:

143

Muitas perguntas boas, vamos nos aprofundar. :)

como você usa isso?

Este é um ótimo tutorial para interagir com o Storage Access Framework no KitKat:

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

A interação com as novas APIs no Lollipop é muito semelhante. Para solicitar que o usuário escolha uma árvore de diretório, você pode iniciar um intent como este:

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

Então, em onActivityResult (), você pode passar o Uri escolhido pelo usuário para a nova classe auxiliar DocumentFile. Aqui está um exemplo rápido que lista os arquivos no diretório escolhido e, em seguida, cria um novo arquivo:

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

O Uri retornado por DocumentFile.getUri()é flexível o suficiente para ser usado com várias APIs de plataformas diferentes. Por exemplo, você pode compartilhar usando Intent.setData()com Intent.FLAG_GRANT_READ_URI_PERMISSION.

Se você quiser acessar esse Uri do código nativo, pode chamar ContentResolver.openFileDescriptor()e usar ParcelFileDescriptor.getFd()ou detachFd()para obter um inteiro descritor de arquivo POSIX tradicional.

Como você verifica se consegue acessar os arquivos / pastas?

Por padrão, o Uris retornado por meio de intents do Storage Access Frameworks não persiste nas reinicializações. A plataforma "oferece" a capacidade de persistir a permissão, mas você ainda precisa "pegar" a permissão se quiser. Em nosso exemplo acima, você chamaria:

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

Você sempre pode descobrir a quais concessões persistentes seu aplicativo tem acesso por meio da ContentResolver.getPersistedUriPermissions()API. Se você não precisar mais acessar um Uri persistente, poderá liberá-lo com ContentResolver.releasePersistableUriPermission().

Isso está disponível no KitKat?

Não, não podemos adicionar retroativamente novas funcionalidades a versões mais antigas da plataforma.

Posso ver quais aplicativos têm acesso a arquivos / pastas?

Atualmente não há interface do usuário que mostra isso, mas você pode encontrar os detalhes na seção "Permissões de Uri concedidas" da adb shell dumpsys activity providerssaída.

O que acontece se um aplicativo for instalado para vários usuários no mesmo dispositivo?

As concessões de permissão de Uri são isoladas por usuário, assim como todas as outras funcionalidades da plataforma multiusuário. Ou seja, o mesmo aplicativo em execução com dois usuários diferentes não tem concessões de permissão Uri sobrepostas ou compartilhadas.

As permissões podem ser revogadas?

O DocumentProvider de apoio pode revogar a permissão a qualquer momento, como quando um documento baseado em nuvem é excluído. A maneira mais comum de descobrir essas permissões revogadas é quando elas desaparecem dos itens ContentResolver.getPersistedUriPermissions()mencionados acima.

As permissões também são revogadas sempre que os dados do aplicativo são apagados para qualquer um dos aplicativos envolvidos na concessão.

Solicitar a permissão funcionaria recursivamente em uma pasta selecionada?

Sim, a ACTION_OPEN_DOCUMENT_TREEintenção oferece acesso recursivo a arquivos e diretórios existentes e recém-criados.

Isso permite a seleção múltipla?

Sim, a seleção múltipla tem suporte desde KitKat, e você pode permitir definindo EXTRA_ALLOW_MULTIPLEao iniciar seu ACTION_OPEN_DOCUMENTintent. Você pode usar Intent.setType()ou EXTRA_MIME_TYPESpara restringir os tipos de arquivos que podem ser selecionados:

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

Existe uma maneira no emulador de experimentar a nova API?

Sim, o dispositivo de armazenamento compartilhado principal deve aparecer no seletor, mesmo no emulador. Se seu aplicativo só usa o Access Storage Framework para acessar o armazenamento compartilhado, você não precisa mais as READ/WRITE_EXTERNAL_STORAGEpermissões em tudo e pode removê-los ou usar o android:maxSdkVersionrecurso apenas solicitá-los em versões de plataforma mais velhos.

O que acontece quando o usuário substitui o cartão SD por outro?

Quando a mídia física está envolvida, o UUID (como o número de série FAT) da mídia subjacente é sempre gravado no Uri retornado. O sistema usa isso para conectar você à mídia que o usuário selecionou originalmente, mesmo se o usuário trocar a mídia entre vários slots.

Se o usuário trocar em um segundo cartão, você precisará solicitar acesso ao novo cartão. Como o sistema se lembra das concessões por UUID, você continuará a ter acesso concedido anteriormente ao cartão original se o usuário o reinserir posteriormente.

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

Jeff Sharkey
fonte
2
Entendo. então a decisão foi que, em vez de adicionar mais à API conhecida (de arquivo), usar uma diferente. ESTÁ BEM. Você pode responder às outras perguntas (escritas no primeiro comentário)? BTW, obrigado por responder a tudo isso.
desenvolvedor Android de
7
@JeffSharkey Existe alguma maneira de fornecer a OPEN_DOCUMENT_TREE uma dica de localização inicial? Os usuários nem sempre são os melhores em navegar para o que o aplicativo precisa acessar.
Justin
2
Existe uma maneira, como alterar o registro de data e hora da última modificação ( método setLastModified () na classe File)? É realmente fundamental para aplicativos como arquivadores.
Quark
1
Digamos que você tenha um documento de pasta Uri armazenado e deseja listar os arquivos mais tarde em algum momento após a reinicialização do dispositivo. DocumentFile.fromTreeUri sempre lista os arquivos raiz, não importando o Uri que você forneceu (até mesmo o Uri filho), então como você cria um DocumentFile em que pode chamar listfiles, onde o DocumentFile não está representando a raiz ou um SingleDocument.
AndersC
1
@JeffSharkey Como esse URI pode ser usado no MediaMuxer, já que aceita uma string como caminho do arquivo de saída? MediaMuxer (java.lang.String, int
Petrakeas
45

No meu projeto Android no Github, link abaixo, você pode encontrar o código de trabalho que permite escrever em extSdCard no Android 5. Ele assume que o usuário dá acesso a todo o cartão SD e, em seguida, permite que você escreva em qualquer lugar neste cartão. (Se você deseja ter acesso apenas a arquivos individuais, as coisas ficam mais fáceis.)

Snipplets de código principal

Acionando a estrutura de acesso ao armazenamento:

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

Lidando com a resposta do 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);
        }
    }
}

Obter um outputStream para um arquivo por meio do Storage Access Framework (fazendo uso do URL armazenado, supondo que este seja o URL da pasta raiz do cartão SD externo)

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

Isso usa os seguintes métodos auxiliares:

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

Referência ao código completo

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

e

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

Jörg Eisfeld
fonte
1
Você pode colocá-lo em um projeto minimizado, que só lida com cartão SD? Além disso, você sabe como posso verificar se todos os armazenamentos externos que estão disponíveis também estão acessíveis, para que eu não solicite sua permissão para nada?
desenvolvedor Android
1
Gostaria de poder votar mais a favor disso. Eu estava no meio do caminho para essa solução e achei a navegação de documentos tão ruim que pensei que estava fazendo algo errado. É bom ter alguma confirmação sobre isso. Obrigado Google ... por nada.
Anthony
1
Sim, para gravar em um SD externo, você não pode mais usar a abordagem de Arquivo normal. Por outro lado, para versões mais antigas do Android e para SD principal, Arquivo ainda é a abordagem mais eficiente. Portanto, você deve usar uma classe de utilitário personalizado para acessar o arquivo.
Jörg Eisfeld
15
@ JörgEisfeld: Tenho um aplicativo que usa File254 vezes. Você pode imaginar consertar isso ?? O Android está se tornando um pesadelo para os desenvolvedores com sua total falta de compatibilidade com versões anteriores. Ainda não encontrei nenhum lugar onde explicassem porque o Google tomou todas essas decisões estúpidas em relação ao armazenamento externo. Alguns alegam "segurança", mas é claro que não faz sentido, pois qualquer aplicativo pode bagunçar o armazenamento interno. Meu palpite é tentar nos forçar a usar seus serviços em nuvem. Felizmente, o root resolve os problemas ... pelo menos para Android <6 ....
Luis A. Florit
1
ESTÁ BEM. Magicamente, depois de reiniciar meu telefone, ele funciona :)
sancho21
0

É apenas uma resposta complementar.

Depois de criar um novo arquivo, pode ser necessário salvar seu local no banco de dados e lê-lo amanhã. Você pode ler recuperá-lo novamente usando este método:

/**
 * 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();
    }
}
Anggrayudi H
fonte