Storage Access Framework: que elija el usuario dónde leer y escribir
Cuando una aplicación necesita tocar ficheros que no son suyos, la respuesta correcta dejó de ser pedir un permiso amplio y pasó a ser pedir un objeto concreto. Esta lección explica el marco de acceso a documentos como un sistema de delegación: el usuario abre un selector controlado por el sistema, elige un documento o un árbol, y la aplicación recibe una referencia que vale exactamente para eso y para nada más. Distingue la apertura de un documento existente de la creación de uno nuevo y de la obtención de una copia efímera, detalla la persistencia de concesiones a través de reinicios y sus límites duros, y aborda el coste real del modelo: cada operación es una llamada entre procesos, no hay rutas, no hay ficheros y las abstracciones cómodas se pagan caras en cuanto hay más de veinte elementos.
Hay una asimetría curiosa en la evolución del almacenamiento en Android. El acceso a los medios se volvió más estrecho pero siguió siendo una API de consulta, con colecciones, columnas y proyecciones. El acceso a documentos arbitrarios tomó el camino contrario: se convirtió en una delegación pura, donde la aplicación no busca nada, no enumera nada y no sabe nada hasta que un ser humano señala con el dedo un objeto concreto en una interfaz que el sistema controla y la aplicación no puede ni ver ni manipular. Esa inversión es deliberada y tiene un nombre técnico incómodo, porque no se trata de un marco de acceso al almacenamiento sino de un protocolo de consentimiento con forma de selector de ficheros. Entenderlo así explica de golpe casi todas sus rarezas: por qué no hay rutas, por qué las referencias hay que guardarlas, por qué el número de concesiones tiene un tope, y por qué recorrer un directorio elegido por el usuario es órdenes de magnitud más lento que recorrer uno propio.
- Distinguir la apertura de un documento existente, la creación de uno nuevo y la obtención de una copia efímera.
- Lanzar los contratos correspondientes desde Compose y filtrar candidatos por tipo MIME.
- Obtener y conservar concesiones persistentes sobre documentos y sobre árboles, respetando sus límites.
- Medir el coste real del modelo y elegir la API adecuada cuando hay que recorrer muchos elementos.
Tres acciones que parecen la misma y no lo son
El marco ofrece tres puertas y confundirlas es el origen de la mitad de los fallos. Abrir un documento devuelve una referencia estable a un objeto que sigue existiendo donde estaba, gestionado por su proveedor original, y esa referencia puede conservarse para volver a él más tarde. Crear un documento pide al usuario un destino y un nombre, y devuelve una referencia a algo que aún está vacío y que tu aplicación llenará. Obtener contenido es distinta en naturaleza: no promete un objeto persistente sino una copia efímera que puede desaparecer en cuanto termine la interacción, y por eso solo sirve cuando lo que quieres es leer una vez e importar.
La consecuencia práctica es una regla sencilla. Si vas a guardar la referencia para usarla mañana, la acción tiene que ser la de abrir documento. Si solo vas a leer los bytes ahora y quedarte con tu propia copia, la de obtener contenido basta y además muestra más orígenes, porque cualquier aplicación puede ofrecerse a producir contenido aunque no gestione documentos.
@Composable
fun ElectorDeDocumento(alAbrir: (Uri) -> Unit) {
val abrir = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri -> uri?.let(alAbrir) }
val crear = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/pdf")
) { uri -> uri?.let(alAbrir) }
Row {
// el filtro es una lista de tipos, no una cadena
Button(onClick = { abrir.launch(arrayOf("application/pdf", "text/*")) }) {
Text("Abrir")
}
Button(onClick = { crear.launch("informe.pdf") }) { Text("Guardar como") }
}
}
El filtrado por tipo MIME funciona igual que en el resto del sistema y con las mismas trampas. Un tipo con comodín amplía la lista, un tipo inexistente la vacía, y ningún filtro es una garantía: el proveedor de origen declara el tipo que quiere, de modo que lo que llega puede no ser lo que dijo ser. La validación del contenido recibido es responsabilidad tuya y es el tema de la última lección de este nivel.
Árboles, persistencia y sus límites duros
Un documento suelto resuelve el caso de importar o exportar una vez. Cuando la aplicación necesita una carpeta de trabajo, un destino de copias de seguridad o un directorio que sincronizar, lo que se pide es un árbol: el usuario elige un directorio y la aplicación obtiene autoridad sobre ese directorio y todo lo que cuelgue de él, incluidos los ficheros que aún no existen.
La referencia que devuelve el selector no es utilizable directamente para listar hijos. Hay que derivar una referencia de hijos a partir del identificador del árbol, y esa derivación es la que mucha gente evita usando la abstracción de conveniencia, que es cómoda y también la causa más común de listados insoportablemente lentos.
val arbol = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocumentTree()
) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
// sin esto, la concesion muere al terminar la tarea
contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
guardarEnPreferencias(uri.toString())
}
Tomar la concesión de forma persistente es obligatorio y hay que hacerlo en el mismo momento en que llega el resultado; más tarde ya no es posible porque el permiso original habrá caducado. Una vez tomada, sobrevive a reinicios del dispositivo y a la muerte del proceso, pero no sobrevive a la desinstalación ni a que el usuario la retire desde los ajustes, ni a que el documento de origen desaparezca. Existe además un tope por aplicación en el número de concesiones que se pueden mantener a la vez, del orden de unos cientos, lo que descarta de plano cualquier diseño que pretenda guardar una concesión por fichero.
La conclusión de diseño es que las concesiones persistentes son recursos escasos y deben tratarse como tales: se guarda el árbol, no sus hijos; se enumeran las concesiones vigentes al arrancar; y se liberan explícitamente las que ya no se usan, porque nadie lo hará por ti y alcanzar el tope produce un fallo tardío y difícil de reproducir.
flowchart TD
A[La app necesita ficheros ajenos] --> B{Que necesita exactamente}
B -->|leer una vez| C[Obtener contenido]
B -->|referencia estable| D[Abrir documento]
B -->|destino nuevo| E[Crear documento]
B -->|carpeta de trabajo| F[Abrir arbol]
C --> G[Copia efimera que hay que importar ya]
D --> H[Tomar concesion persistente]
E --> H
F --> H
H --> I[Guardar la referencia en almacenamiento propio]
I --> J[Reinicio del dispositivo]
J --> K[La concesion sigue viva hasta desinstalar o revocar]El coste que no aparece en la documentación
Detrás de cada operación hay una llamada entre procesos hacia el proveedor de documentos, que a su vez puede estar hablando con la red o con un sistema de ficheros ajeno. Consultar el nombre de un fichero es una consulta; consultar su tamaño es otra; comprobar si es un directorio es otra más. La abstracción de conveniencia que devuelve objetos con métodos para cada propiedad hace una llamada por método, de modo que listar doscientos elementos y mostrar su nombre y tamaño puede acabar siendo mil consultas entre procesos y varios segundos de espera.
La alternativa es tratarlo como lo que es, un proveedor de contenidos, y hacer una única consulta sobre la referencia de hijos pidiendo todas las columnas necesarias de golpe.
val hijos = DocumentsContract.buildChildDocumentsUriUsingTree(
arbol,
DocumentsContract.getTreeDocumentId(arbol),
)
contentResolver.query(
hijos,
arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_SIZE,
),
null, null, null,
)?.use { c -> while (c.moveToNext()) { /* una sola llamada para todo */ } }
La escritura tiene su propia trampa. Abrir un documento en modo de escritura no lo trunca: si el contenido nuevo es más corto que el anterior, quedan bytes viejos al final y el fichero resultante es basura sintácticamente válida hasta la mitad. El modo que trunca hay que pedirlo explícitamente, y conviene además envolver la operación de forma que un fallo a mitad no deje al usuario con un documento corrupto donde antes tenía uno bueno.
contentResolver.openOutputStream(uri, "wt")?.use { salida ->
salida.write(contenido) // la t del modo es la que trunca
}
Queda un límite que conviene conocer antes de prometer funcionalidad: hay directorios que el selector no permite elegir. La raíz del almacenamiento interno y los directorios de datos de otras aplicaciones están vetados desde la API 30, y ninguna insistencia los abre. Si un requisito de producto depende de acceder ahí, el requisito está mal planteado.
Delegación, no permiso
No se pide acceso al almacenamiento: se pide un objeto concreto que el usuario señala.
Guarda el árbol
Una concesión por directorio de trabajo; una por fichero agota el tope y rompe la aplicación.
Toma el permiso ya
La persistencia se solicita en el instante del resultado; después la ventana está cerrada.
Cada método, una llamada
Listar con la abstracción cómoda multiplica las llamadas entre procesos; consulta con proyección.
La resistencia que casi todo el mundo siente ante este marco viene de una expectativa que la plataforma abandonó hace años: la de que detrás de una referencia hay un fichero en un disco. No lo hay necesariamente. El proveedor que atiende la referencia puede ser un servicio de almacenamiento en la nube que descarga bajo demanda, un gestor de documentos cifrados que descifra al abrir, una aplicación que genera el contenido en el momento, o un perfil de trabajo separado del personal por una frontera que tu proceso no puede cruzar. Todas las peculiaridades que resultan molestas se derivan de esa generalidad. No hay ruta porque no hay sistema de ficheros común. No hay tamaño fiable porque el objeto puede no existir aún materialmente. No se puede escribir en mitad de un fichero con garantías porque el proveedor puede estar sincronizando. No se puede listar rápido porque cada respuesta puede requerir una consulta remota. Y no se puede recorrer libremente porque el consentimiento se otorgó sobre un nodo del árbol y no sobre un volumen. Cuando se acepta esa descripción, el diseño correcto aparece solo: la aplicación no trabaja sobre los documentos ajenos sino sobre copias propias, importa de forma explícita, exporta de forma explícita, y trata cada lectura y cada escritura como una operación de red que puede tardar, fallar o encontrarse con que el objeto ya no existe. El error caro no es técnico sino conceptual, y consiste en construir la lógica de la aplicación asumiendo un directorio local con el que se puede contar, para descubrir meses después que la mitad de los usuarios lo tenían apuntando a un servicio remoto, que las operaciones en lote tardaban minutos, y que la referencia guardada en la base de datos había dejado de resolver porque alguien renombró una carpeta desde otro dispositivo. Delegar la elección al usuario es liberador precisamente porque también delega el control, y una aplicación seria diseña para no tenerlo.
- Pide un documento con la acción de obtener contenido, guarda la referencia y vuelve a abrirla tras reiniciar la aplicación; explica el fallo.
- Repite con la acción de abrir documento y concesión persistente, y comprueba que sobrevive incluso a un reinicio del dispositivo.
- Elige un árbol con doscientos ficheros y cronometra el listado con la abstracción de conveniencia frente a una consulta con proyección.
- Toma concesiones persistentes en bucle hasta alcanzar el tope de la aplicación y anota el error exacto que produce.
- Intenta seleccionar la raíz del almacenamiento interno y el directorio de datos de otra aplicación, y documenta qué ofrece el selector en su lugar.