¿Cómo utilizar la nueva API de acceso a la tarjeta SD presentada para Android 5.0 (Lollipop)?

118

Antecedentes

En Android 4.4 (KitKat), Google ha restringido bastante el acceso a la tarjeta SD.

A partir de Android Lollipop (5.0), los desarrolladores pueden usar una nueva API que le pide al usuario que confirme para permitir el acceso a carpetas específicas, como está escrito en esta publicación de Grupos de Google .

El problema

La publicación le dirige a visitar dos sitios web:

Esto parece un ejemplo interno (tal vez se muestre en las demostraciones de API más adelante), pero es bastante difícil entender qué está pasando.

Esta es la documentación oficial de la nueva API, pero no brinda suficientes detalles sobre cómo usarla.

Esto es lo que te dice:

Si realmente necesita acceso completo a un subárbol completo de documentos, comience iniciando ACTION_OPEN_DOCUMENT_TREE para permitir que el usuario elija un directorio. Luego, pase el getData () resultante a fromTreeUri (Contexto, Uri) para comenzar a trabajar con el árbol seleccionado por el usuario.

Mientras navega por el árbol de instancias de DocumentFile, siempre puede usar getUri () para obtener el Uri que representa el documento subyacente para ese objeto, para usar con openInputStream (Uri), etc.

Para simplificar su código en dispositivos que ejecutan KITKAT o una versión anterior, puede usar fromFile (Archivo) que emula el comportamiento de un DocumentsProvider.

Las preguntas

Tengo algunas preguntas sobre la nueva API:

  1. ¿Cómo lo usas realmente?
  2. Según la publicación, el sistema operativo recordará que la aplicación recibió un permiso para acceder a los archivos / carpetas. ¿Cómo verifica si puede acceder a los archivos / carpetas? ¿Existe una función que me devuelva la lista de archivos / carpetas a los que puedo acceder?
  3. ¿Cómo maneja este problema en Kitkat? ¿Es parte de la biblioteca de soporte?
  4. ¿Hay una pantalla de configuración en el sistema operativo que muestre qué aplicaciones tienen acceso a qué archivos / carpetas?
  5. ¿Qué sucede si se instala una aplicación para varios usuarios en el mismo dispositivo?
  6. ¿Existe alguna otra documentación / tutorial sobre esta nueva API?
  7. ¿Se pueden revocar los permisos? Si es así, ¿se está enviando una intención a la aplicación?
  8. ¿Pedir el permiso funcionaría de forma recursiva en una carpeta seleccionada?
  9. ¿El uso del permiso también permitiría dar al usuario la posibilidad de realizar una selección múltiple por elección del usuario? ¿O la aplicación necesita indicar específicamente a la intención qué archivos / carpetas permitir?
  10. ¿Hay alguna forma en el emulador de probar la nueva API? Quiero decir, tiene una partición de tarjeta SD, pero funciona como el almacenamiento externo principal, por lo que ya se otorga todo el acceso (con un simple permiso).
  11. ¿Qué sucede cuando el usuario reemplaza la tarjeta SD por otra?
desarrollador de Android
fuente
FWIW, AndroidPolice tenía un pequeño artículo sobre esto hoy.
fattire
@fattire Gracias. pero están mostrando lo que ya he leído. Aunque son buenas noticias.
desarrollador de Android
32
Cada vez que sale un nuevo sistema operativo, hacen nuestra vida más complicada ...
Phantômaxx
@Funkystein cierto. Ojalá lo hicieran en Kitkat. Ahora hay 3 tipos de comportamientos que manejar.
desarrollador de Android
1
@Funkystein, no lo sé. Lo usé hace mucho tiempo. No es tan malo hacer esta comprobación, creo. Debes recordar que también son humanos, por lo que pueden cometer errores y cambiar de opinión de vez en cuando ...
desarrollador de Android

Respuestas:

143

Muchas buenas preguntas, profundicemos :)

¿Como lo usas?

Aquí hay un gran tutorial para interactuar con Storage Access Framework en KitKat:

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

Interactuar con las nuevas API en Lollipop es muy similar. Para pedirle al usuario que elija un árbol de directorios, puede iniciar una intención como esta:

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

Luego, en su onActivityResult (), puede pasar el Uri elegido por el usuario a la nueva clase auxiliar DocumentFile. Aquí hay un ejemplo rápido que enumera los archivos en el directorio seleccionado y luego crea un nuevo archivo:

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

El Uri devuelto por DocumentFile.getUri()es lo suficientemente flexible como para usarlo con distintas API de plataforma. Por ejemplo, puede compartirlo Intent.setData()con Intent.FLAG_GRANT_READ_URI_PERMISSION.

Si desea acceder a ese Uri desde el código nativo, puede llamar ContentResolver.openFileDescriptor()y luego usar ParcelFileDescriptor.getFd()o detachFd()para obtener un entero descriptor de archivo POSIX tradicional.

¿Cómo verifica si puede acceder a los archivos / carpetas?

De forma predeterminada, los Uris devueltos a través de los intentos de Storage Access Frameworks no se conservan durante los reinicios. La plataforma "ofrece" la capacidad de conservar el permiso, pero aún necesita "tomar" el permiso si lo desea. En nuestro ejemplo anterior, llamaría:

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

Siempre puede averiguar a qué concesiones persistentes tiene acceso su aplicación a través de la ContentResolver.getPersistedUriPermissions()API. Si ya no necesita acceso a un Uri persistente, puede liberarlo con ContentResolver.releasePersistableUriPermission().

¿Está disponible en KitKat?

No, no podemos agregar nuevas funciones de manera retroactiva a versiones anteriores de la plataforma.

¿Puedo ver qué aplicaciones tienen acceso a archivos / carpetas?

Actualmente no hay una interfaz de usuario que muestre esto, pero puede encontrar los detalles en la sección de adb shell dumpsys activity providerssalida "Permisos Uri otorgados" .

¿Qué sucede si se instala una aplicación para varios usuarios en el mismo dispositivo?

Las concesiones de permisos Uri están aisladas por usuario, al igual que todas las demás funciones de la plataforma multiusuario. Es decir, la misma aplicación que se ejecuta con dos usuarios diferentes no tiene concesiones de permisos Uri compartidas o superpuestas.

¿Se pueden revocar los permisos?

El DocumentProvider de respaldo puede revocar el permiso en cualquier momento, como cuando se elimina un documento basado en la nube. La forma más común de descubrir estos permisos revocados es cuando desaparecen de los ContentResolver.getPersistedUriPermissions()mencionados anteriormente.

Los permisos también se revocan cada vez que se borran los datos de la aplicación para cualquiera de las aplicaciones involucradas en la concesión.

¿Pedir el permiso funcionaría de forma recursiva en una carpeta seleccionada?

Sí, la ACTION_OPEN_DOCUMENT_TREEintención le brinda acceso recursivo a archivos y directorios existentes y recién creados.

¿Esto permite una selección múltiple?

Sí, la selección múltiple ha sido compatible desde KitKat, y puede permitirla configurando EXTRA_ALLOW_MULTIPLEal iniciar su ACTION_OPEN_DOCUMENTintento. Puede utilizar Intent.setType()o EXTRA_MIME_TYPESpara limitar los tipos de archivos que se pueden seleccionar:

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

¿Hay alguna forma en el emulador de probar la nueva API?

Sí, el dispositivo de almacenamiento compartido principal debería aparecer en el selector, incluso en el emulador. Si su aplicación sólo utiliza el Marco de almacenamiento de acceso para acceder a almacenamiento compartido, que ya no necesita los READ/WRITE_EXTERNAL_STORAGEpermisos en absoluto y puede eliminarlos o utilizar la android:maxSdkVersionfunción para solicitar sólo a ellos en las versiones de plataforma de mayor edad.

¿Qué sucede cuando el usuario reemplaza la tarjeta SD por otra?

Cuando se trata de medios físicos, el UUID (como el número de serie FAT) del medio subyacente siempre se graba en el Uri devuelto. El sistema utiliza esto para conectarlo a los medios que el usuario seleccionó originalmente, incluso si el usuario intercambia los medios entre varias ranuras.

Si el usuario cambia una segunda tarjeta, deberá solicitarlo para obtener acceso a la nueva tarjeta. Dado que el sistema recuerda las subvenciones por UUID, continuará teniendo acceso previamente otorgado a la tarjeta original si el usuario la reintroduce más tarde.

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

Jeff Sharkey
fuente
2
Veo. por lo que la decisión fue que en lugar de agregar más a la API conocida (de Archivo), usar una diferente. OKAY. ¿Puede responder las otras preguntas (escritas en el primer comentario)? Por cierto, gracias por responder a todo esto.
desarrollador de Android
7
@JeffSharkey ¿Alguna forma de proporcionar a OPEN_DOCUMENT_TREE una pista de ubicación inicial? Los usuarios no siempre son los mejores para navegar a lo que la aplicación necesita acceder.
Justin
2
¿Hay alguna manera de cambiar la marca de tiempo de la última modificación ( método setLastModified () en la clase de archivo)? Es realmente fundamental para aplicaciones como archivadores.
Quark
1
Supongamos que tiene un documento de carpeta Uri almacenado y desea listar los archivos más tarde en algún momento después de reiniciar el dispositivo. DocumentFile.
AndersC
1
@JeffSharkey ¿Cómo se puede usar este URI en MediaMuxer, ya que acepta una cadena como ruta del archivo de salida? MediaMuxer (java.lang.String, int
Petrakeas
45

En mi proyecto de Android en Github, vinculado a continuación, puede encontrar código de trabajo que permite escribir en extSdCard en Android 5. Se asume que el usuario da acceso a toda la tarjeta SD y luego le permite escribir en todas partes en esta tarjeta. (Si desea tener acceso solo a archivos individuales, las cosas se vuelven más fáciles).

Fragmentos de código principal

Activación del marco de acceso al almacenamiento:

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

Manejo de la respuesta de 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);
        }
    }
}

Obtener un outputStream para un archivo a través de Storage Access Framework (haciendo uso de la URL almacenada, asumiendo que esta es la URL de la carpeta raíz de la tarjeta SD externa)

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

Esto utiliza los siguientes métodos de ayuda:

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

Referencia al código completo

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

y

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

Jörg Eisfeld
fuente
1
¿Puede ponerlo en un proyecto minimizado, que solo maneja tarjetas SD? Además, ¿sabe cómo puedo comprobar si todos los almacenamientos externos que están disponibles también son accesibles, para no solicitar su permiso por nada?
desarrollador de Android
1
Ojalá pudiera votar más a favor de esto. Estaba a medio camino de esta solución y encontré la navegación de documentos tan horrible que pensé que lo estaba haciendo mal. Es bueno tener alguna confirmación sobre esto. Gracias Google ... por nada.
Anthony
1
Sí, para escribir en SD externa ya no puede utilizar el método de archivo normal. Por otro lado, para las versiones anteriores de Android y para SD principal, File sigue siendo el enfoque más eficiente. Por lo tanto, debe utilizar una clase de utilidad personalizada para acceder a los archivos.
Jörg Eisfeld
15
@ JörgEisfeld: Tengo una aplicación que usa File254 veces. ¿Te imaginas arreglar eso? Android se está convirtiendo en una pesadilla para los desarrolladores con su total falta de compatibilidad con versiones anteriores. Todavía no encontré ningún lugar donde explicaran por qué Google tomó todas estas decisiones estúpidas con respecto al almacenamiento externo. Algunos afirman "seguridad", pero por supuesto es una tontería, ya que cualquier aplicación puede estropear el almacenamiento interno. Supongo que intentar forzarnos a utilizar sus servicios en la nube. Afortunadamente, el enraizamiento resuelve los problemas ... al menos para Android <6 ....
Luis A. Florit
1
OKAY. Mágicamente, después de reiniciar mi teléfono, funciona :)
sancho21
0

Es solo una respuesta complementaria.

Después de crear un nuevo archivo, es posible que deba guardar su ubicación en su base de datos y leerlo mañana. Puede leer recuperarlo nuevamente 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
fuente