wandres.dev
INTEROPERAR · compartir entre apps

FileProvider: exponer un archivo sin exponer tu almacenamiento

Entre el fichero privado que solo tu proceso puede abrir y la ruta pública que cualquiera puede recorrer existe una tercera figura: una referencia opaca, acotada a un elemento concreto, con permisos que caducan solos. Esta lección explica por qué el esquema de fichero fue retirado de la circulación, cómo se declara un proveedor de ficheros con su autoridad y su mapa de directorios expuestos, y qué significa exactamente que un proveedor no exportado pueda entregar contenido a terceros. Analiza después el ciclo de vida de una concesión temporal de URI, la diferencia entre las banderas del intent y la concesión explícita, la duración real del permiso, y los errores de configuración que convierten una comodidad en una filtración: mapas demasiado amplios, autoridades colisionantes y rutas construidas a partir de nombres ajenos.

⏱ 20 min

Durante los primeros años de Android compartir un fichero consistía en pasar su ruta. Era directo, era barato y era exactamente igual de seguro que dejar la puerta abierta: la ruta revelaba dónde vive tu aplicación, obligaba al receptor a tener permiso general de almacenamiento para poder leerla, no distinguía entre el fichero que querías compartir y sus vecinos del mismo directorio, y no caducaba jamás. El modelo actual sustituye esa ruta por una referencia opaca que no dice nada del sistema de ficheros, que apunta a un único elemento, que solo funciona para quien recibió permiso y que deja de funcionar sola al cabo de un rato. La pieza que fabrica esa referencia es un proveedor de contenidos especializado, y es probablemente el componente cuya configuración se copia y pega más veces sin entenderse, con el resultado previsible de aplicaciones que creen estar compartiendo un fichero y en realidad han abierto su directorio entero.

🎯 Al terminar esta lección sabrás
  • Explicar por qué el esquema de fichero produce una excepción y qué problemas concretos resolvía retirarlo.
  • Declarar un proveedor de ficheros con una autoridad correcta y un mapa de directorios mínimo.
  • Entender por qué el proveedor no se exporta y aun así puede entregar contenido a otras aplicaciones.
  • Manejar el ciclo de vida de una concesión temporal de URI y saber cuándo hay que concederla o revocarla a mano.

Por qué la ruta dejó de ser una opción

Desde la API 24, el modo estricto lanza FileUriExposedException en cuanto un intent que contiene un esquema de fichero abandona el proceso. No es una advertencia ni una recomendación: es un fallo duro, y su motivo es que la ruta transfiere un problema al receptor en lugar de resolverlo en el emisor. Quien recibe una ruta necesita permiso de lectura sobre esa zona del almacenamiento, lo que significa que el modelo antiguo empujaba a todas las aplicaciones receptoras a pedir un permiso amplio para poder atender un caso estrecho.

Hay tres defectos más que se suelen olvidar. Una ruta describe la estructura interna de tu aplicación, y esa estructura es información útil para quien quiera atacarla. Una ruta no tiene granularidad: quien puede leer un fichero del directorio puede leer todos. Y una ruta no tiene caducidad, de modo que la aplicación que la recibió hoy puede volver a leerla dentro de seis meses sin que nadie lo autorice de nuevo.

La referencia de contenido corrige los cuatro problemas a la vez. Es opaca, porque el identificador que contiene no guarda relación reconocible con la ruta real. Es concreta, porque apunta a un elemento y no a un directorio. Es autorizada, porque solo el proceso al que se concedió el permiso puede resolverla. Y es temporal, porque el permiso muere con la tarea que lo recibió.

Declarar el proveedor y dibujar el mapa

El proveedor se declara en el manifiesto como cualquier otro componente, pero con una combinación de atributos que a primera vista parece contradictoria: no exportado y a la vez capaz de conceder permisos. La autoridad debe ser única en todo el dispositivo, y por eso se deriva siempre del identificador de aplicación en lugar de escribirse a mano; dos aplicaciones instaladas con la misma autoridad provocan que la segunda instalación falle sin explicación clara.

<provider
    android:name="androidx.core.content.FileProvider"
    android:authorities="${applicationId}.fileprovider"
    android:exported="false"
    android:grantUriPermissions="true">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/rutas_compartidas" />
</provider>

El recurso asociado es el mapa de lo que existe para el exterior. Cada entrada asocia un nombre público con un subdirectorio real, y todo lo que no aparezca en el mapa sencillamente no puede convertirse en referencia. Aquí está el error de configuración más caro del componente: usar una ruta vacía, que equivale a exponer la raíz del directorio correspondiente y por tanto a poner al alcance del exterior las bases de datos, las preferencias y los ficheros internos de la aplicación.

<paths>
    <!-- correcto: un subdirectorio dedicado, nada mas -->
    <cache-path name="compartidos" path="para_compartir/" />
    <files-path name="informes" path="informes/" />

    <!-- peligroso: la ruta vacia expone la raiz entera -->
    <!-- <files-path name="todo" path="." /> -->
</paths>

Las entradas disponibles cubren cada zona de almacenamiento con su propia etiqueta: la memoria interna privada, la caché interna, los directorios privados en el almacenamiento externo y sus cachés, y los directorios públicos de medios. Elegir la zona equivocada es un fallo silencioso en dispositivos concretos, porque un directorio externo puede no estar montado cuando el usuario ha extraído la tarjeta o cuando el perfil activo no es el principal. La caché interna es la elección por defecto para material efímero de compartición, con la ventaja añadida de que el sistema puede reclamarla bajo presión de espacio y la desventaja simétrica de que puede desaparecer antes de que el receptor la lea.

Con el mapa en su sitio, obtener la referencia es una única llamada que traduce un fichero real a su nombre público. La llamada falla con una excepción de argumento ilegal si el fichero está fuera del mapa, lo cual es exactamente el comportamiento deseado: el error aparece en tu proceso, en desarrollo, y no como una filtración silenciosa en producción.

val fichero = File(context.cacheDir, "para_compartir/informe.pdf")
val uri = FileProvider.getUriForFile(
    context,
    "${context.packageName}.fileprovider",
    fichero,
)

La concesión temporal y su ciclo de vida

Que el proveedor no esté exportado significa que nadie puede consultarlo por iniciativa propia. Que tenga activada la concesión de permisos significa que tú puedes autorizar accesos puntuales sobre elementos concretos. Las dos cosas juntas describen el modelo entero: el acceso no se concede al componente, se concede a una referencia, a un proceso y por un tiempo.

La vía habitual es la bandera del intent, que autoriza a quien acabe atendiendo ese intent y solo para la referencia que lleva dentro y los elementos de su clip. La vía explícita hace falta cuando no hay intent de por medio, cuando el destinatario se conoce de antemano o cuando hay que autorizar referencias que no viajan en los campos que la bandera cubre.

// via habitual: la bandera acompana al intent
val intent = Intent(Intent.ACTION_VIEW).apply {
    setDataAndType(uri, "application/pdf")
    addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}

// via explicita: destinatario conocido, revocacion bajo tu control
context.grantUriPermission(paqueteDestino, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
// mas tarde, cuando el contenido deja de ser valido
context.revokeUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)

Hay un tercer escenario que rompe las dos vías anteriores y aparece constantemente: entregar una referencia dentro de un intent pendiente, típicamente en la acción de una notificación. El intent pendiente se construye ahora y se dispara más tarde, posiblemente en otro proceso, y las banderas de concesión hay que ponerlas en el intent envuelto y no en el pendiente. Además, desde la API 31 todo intent pendiente debe declarar explícitamente si es mutable, y un pendiente mutable que contiene una referencia concedida es exactamente el material del que están hechas las escaladas de privilegio.

val ver = Intent(Intent.ACTION_VIEW).apply {
    setDataAndType(uri, "application/pdf")
    addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)   // aqui, no en el pendiente
}

val pendiente = PendingIntent.getActivity(
    context, 0, ver,
    PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)

La duración es la parte que casi nadie mide. Una concesión otorgada por bandera vive mientras viva la tarea que la recibió, lo que puede ser mucho más de lo que imaginas: si el usuario deja la aplicación receptora abierta en segundo plano, el permiso sigue vigente. Una concesión explícita vive hasta que se revoque o hasta que el dispositivo se reinicie. En ningún caso sobrevive a la desinstalación del emisor, porque desaparece el proveedor que la sostenía.

flowchart TD
A[Fichero en un directorio del mapa] --> B[getUriForFile]
B --> C[Referencia opaca con autoridad propia]
C --> D{Como se autoriza}
D -->|bandera en el intent| E[Permiso para quien atienda el intent]
D -->|concesion explicita| F[Permiso para un paquete concreto]
E --> G[El receptor abre un descriptor de fichero]
F --> G
G --> H{Cuando caduca}
H -->|bandera| I[Al terminar la tarea receptora]
H -->|explicita| J[Al revocar o al reiniciar]
🔗

Autoridad derivada

La autoridad se construye desde el identificador de aplicación: escrita a mano, colisiona y bloquea instalaciones.

🗺️

El mapa es el límite

Lo que no está en el recurso de rutas no puede exponerse; una ruta vacía expone el directorio entero.

🚪

Cerrado y generoso

No exportado impide consultas ajenas; la concesión de permisos habilita autorizaciones puntuales.

El permiso caduca

La concesión por bandera muere con la tarea receptora, no con tu pantalla ni con tu proceso.

Un proveedor de ficheros no comparte ficheros: publica un espacio de nombres

La forma habitual de pensar en este componente es como en un traductor de rutas a referencias, y esa lectura funciona hasta el día en que causa un incidente. Lo que realmente ocurre al declarar el mapa de rutas es que estás definiendo un espacio de nombres público sobre una parte de tu almacenamiento privado, y ese espacio tiene todas las propiedades incómodas de cualquier interfaz pública. Tiene superficie: cada entrada del mapa amplía el conjunto de ficheros que, con la referencia adecuada, alguien podría llegar a abrir. Tiene compatibilidad: si otra aplicación guarda una referencia tuya y tú reorganizas tus directorios, la referencia se rompe sin previo aviso y el fallo aparece en un proceso que no controlas. Y tiene una relación peligrosa con los nombres, porque la referencia se construye a partir de la ruta relativa dentro del subdirectorio expuesto, de modo que cualquier código que fabrique esa ruta a partir de un nombre recibido de fuera introduce una vía de recorrido de directorios en el corazón de tu almacenamiento. Ese es el punto donde el componente pasa de ser una comodidad a ser una decisión arquitectónica: el subdirectorio expuesto debe existir para ese único fin, debe contener copias y no originales, sus nombres deben generarse por ti y no derivarse de datos ajenos, y su contenido debe poder desaparecer sin que nada de tu aplicación deje de funcionar. Quien lo trata así descubre además una propiedad útil que la lectura ingenua no ofrece: como lo compartido es una copia deliberada, se puede degradar antes de entregarla, reduciendo la resolución de una imagen, eliminando metadatos de localización o recortando un documento a las páginas que el usuario eligió. La referencia opaca deja de ser entonces un requisito impuesto por la plataforma y se convierte en lo que siempre debió ser: el punto exacto donde decides, con toda la información delante, qué parte de lo tuyo cruza la frontera.

⚔️ Configura mal el proveedor a propósito y mide el daño
  1. Declara un mapa con una ruta vacía, obtén una referencia a tu propia base de datos y compruébalo abriéndola desde otra aplicación de prueba.
  2. Corrige el mapa a un subdirectorio dedicado y verifica que la llamada de traducción ahora lanza excepción para ficheros fuera de él.
  3. Comparte un documento sin la bandera de lectura y localiza la excepción de seguridad en el proceso receptor con adb logcat.
  4. Concede el permiso de forma explícita a un paquete, revócalo mientras la otra aplicación sigue abierta y observa qué pasa al releer.
  5. Construye la ruta del fichero a compartir a partir de un nombre recibido en un intent y demuestra el recorrido de directorios con una secuencia de dos puntos.