wandres.dev
PERMISOS Y PRIVACIDAD · el contrato con el usuario

Scoped storage y el fin del acceso libre

Durante una década, un solo permiso concedía la lectura completa de todo lo que el usuario guardaba en su teléfono. Esta lección reconstruye el desmantelamiento de ese modelo: por qué el almacenamiento compartido dejó de ser un sistema de ficheros para convertirse en un índice mediado, cómo se escribe y se lee a través de `MediaStore` y qué operaciones exigen confirmación humana, por qué el `Photo Picker` es la respuesta correcta a la inmensa mayoría de los casos y no requiere ningún permiso, cómo funcionan los permisos granulares de medios y el acceso parcial, y por qué el acceso total a todos los archivos casi nunca es para ti.

⏱ 23 min

El almacenamiento externo de Android nació como un sistema de ficheros compartido, plano y sin dueños, heredado directamente de la lógica de una tarjeta de memoria extraíble. Un único permiso de lectura abría todo: las fotos, los documentos, las copias de seguridad de otras aplicaciones, los registros de conversaciones que algún desarrollador descuidado había dejado en texto plano. La consecuencia fue previsible y está bien documentada: durante años, aceptar el permiso de almacenamiento de una linterna equivalía a entregarle el archivo completo de la vida digital de una persona. El desmantelamiento de ese modelo, que ocupó cuatro versiones de la plataforma y rompió miles de aplicaciones, es probablemente el cambio más profundo que Android ha hecho jamás en su superficie de API. Y su lógica es unitaria: el almacenamiento compartido deja de ser un sistema de ficheros al que se accede con rutas y pasa a ser una base de datos mediada a la que se accede con identificadores, consultas y consentimientos concretos.

🎯 Al terminar esta lección sabrás
  • Explicar el modelo de almacenamiento acotado y distinguir el directorio privado de la aplicación del espacio compartido.
  • Escribir y leer medios a través de MediaStore, incluidas las operaciones sobre contenido ajeno que exigen confirmación del usuario.
  • Integrar el Photo Picker como vía preferente y reconocer cuándo deja de ser suficiente.
  • Manejar los permisos granulares de medios y el acceso parcial introducido en las versiones recientes.

De un sistema de ficheros a un índice mediado

El modelo actual parte de una separación tajante. Cada aplicación tiene un directorio privado, dentro y fuera de la memoria interna, sobre el que ejerce control absoluto sin necesidad de ningún permiso y que el sistema borra cuando la aplicación se desinstala. Todo lo que sea estado interno, cachés, descargas temporales o material de trabajo pertenece ahí, y la regla práctica es sencilla: si el usuario no necesita ver ese fichero desde otra aplicación, no tiene ningún motivo para estar fuera.

Fuera de ese directorio está el espacio compartido, y ahí ya no hay rutas sino colecciones. Las imágenes, los vídeos, el audio y las descargas viven en tablas indexadas por el proveedor de medios, y se manipulan con las mismas primitivas que cualquier otro proveedor de contenidos: consultas, inserciones, actualizaciones e identificadores. La ruta absoluta sigue existiendo por debajo, pero dejó de ser la interfaz.

La transición se ejecutó en dos tiempos deliberados. La API 29 introdujo el modelo y ofreció una válvula de escape para quien no llegara a tiempo, requestLegacyExternalStorage, que restauraba el comportamiento antiguo. La API 30 cerró la válvula: el atributo se ignora y no hay marcha atrás. Ese patrón de una versión de aviso seguida de una versión de aplicación es el mismo que Android ha usado después con cada endurecimiento de privacidad, y conviene reconocerlo porque anticipa el calendario de los que vengan.

📦

Espacio privado

Sin permisos, sin consultas, sin sorpresas. Control total sobre ficheros que nadie más ve y que desaparecen con la desinstalación. Es el destino por defecto de casi todo lo que escribe una aplicación.

🗂️

Espacio compartido

Mediado por el proveedor de medios. Escribes lo tuyo sin permiso, lees lo ajeno con permiso, y modificas lo ajeno solo con una confirmación explícita del usuario para cada operación.

La asimetría clave del modelo, y la que más cuesta interiorizar a quien viene del mundo antiguo, es esta: contribuir contenido nuevo al espacio compartido nunca requiere permiso, mientras que leer lo que otros contribuyeron siempre lo requiere. Guardar la foto que tu aplicación acaba de tomar en la galería del usuario es una operación libre. Enumerar las fotos que ya estaban ahí no lo es, y no lo será nunca más.

Escribir y modificar a través de MediaStore

La inserción de un medio nuevo sigue un patrón de dos fases que evita que otras aplicaciones vean ficheros a medio escribir. Primero se inserta el registro marcado como pendiente, después se vuelca el contenido en el flujo de salida asociado, y solo al terminar se retira la marca y el elemento aparece en la galería.

val valores = ContentValues().apply {
    put(MediaStore.Images.Media.DISPLAY_NAME, "recibo.jpg")
    put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
    put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/MiApp")
    put(MediaStore.Images.Media.IS_PENDING, 1)
}

val destino = MediaStore.Images.Media.getContentUri(
    MediaStore.VOLUME_EXTERNAL_PRIMARY,
)
val uri = resolver.insert(destino, valores) ?: return

resolver.openOutputStream(uri)?.use { salida -> mapa.compress(JPEG, 90, salida) }

resolver.update(uri, ContentValues().apply { put(IS_PENDING, 0) }, null, null)

El campo RELATIVE_PATH sustituye a la construcción manual de rutas y es la única forma admitida de decidir en qué carpeta visible aterriza el fichero. Lo que no puedes hacer es inventarte una carpeta de primer nivel arbitraria: el sistema exige que el destino cuelgue de una de las colecciones estándar, y esa restricción es exactamente lo que impidió que la raíz del almacenamiento siguiera llenándose de directorios con nombres de producto.

Modificar o borrar contenido que tu aplicación no creó es un caso distinto y mucho más interesante, porque no basta con tener el permiso de lectura. El sistema exige una confirmación por operación, que se solicita construyendo una petición de escritura y lanzándola como una actividad cuyo resultado indica si el usuario accedió.

val peticion = MediaStore.createWriteRequest(resolver, listOf(uriAjena))
lanzadorDeIntent.launch(IntentSenderRequest.Builder(peticion).build())

Este mecanismo, junto con sus hermanos para borrar y para enviar a la papelera, convierte una operación destructiva sobre datos ajenos en una decisión consciente del usuario sobre elementos concretos. Es la traducción al almacenamiento de la misma doctrina que gobierna el resto del sistema: el permiso amplio se sustituye por el consentimiento puntual y verificable.

ℹ️
El permiso de escritura que ya no escribe

WRITE_EXTERNAL_STORAGE no hace absolutamente nada desde la API 29 y ni siquiera se concede desde la API 33. Sigue apareciendo en miles de manifiestos por inercia y por copia de respuestas antiguas, donde solo consigue dos efectos: alarmar al revisor de la tienda y aumentar la superficie de permisos que anuncias. Declararlo con maxSdkVersion fijado en 28, o eliminarlo directamente si tu mínimo es superior, es una de las limpiezas más baratas y de mayor efecto que puedes hacer hoy.

El Photo Picker: pedir nada y obtener lo necesario

La observación empírica que ordena todo este terreno es que la inmensa mayoría de las aplicaciones que piden acceso a la galería no quieren la galería: quieren una foto. Una foto de perfil, un adjunto, un comprobante. Para ese caso, que cubre casi todo el volumen real de peticiones de almacenamiento del ecosistema, existe una solución que no requiere ningún permiso.

El selector visual del sistema se lanza como cualquier otro contrato de resultado, muestra al usuario su propia galería en un proceso que no es el tuyo, y te devuelve un identificador de contenido con acceso concedido únicamente a lo que la persona seleccionó. Tu aplicación nunca ve el resto. No hay diálogo de permiso porque no hay nada que conceder: la selección es el consentimiento.

private val elegirFoto = registerForActivityResult(
    ActivityResultContracts.PickVisualMedia(),
) { uri -> uri?.let { cargarAvatar(it) } }

private fun alPulsarCambiarAvatar() {
    elegirFoto.launch(
        PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
    )
}

Existe también la variante de selección múltiple con un tope de elementos, y la biblioteca de compatibilidad lo respalda en versiones antiguas apoyándose en los servicios de Google Play, de modo que la ruta funciona muy por debajo de la versión en que se introdujo. La consecuencia es que la excusa del soporte de versiones antiguas ya no sostiene la petición del permiso.

Para documentos que no son medios existe el marco de acceso a documentos, con sus acciones de abrir, crear y abrir un árbol completo. Su propiedad más útil es la persistencia: el acceso obtenido puede conservarse entre reinicios tomando explícitamente el permiso persistible sobre el identificador, lo que permite construir editores que recuerdan sus ficheros sin ningún permiso declarado.

private val abrirDocumento = registerForActivityResult(
    ActivityResultContracts.OpenDocument(),
) { uri ->
    uri?.let {
        contentResolver.takePersistableUriPermission(
            it,
            Intent.FLAG_GRANT_READ_URI_PERMISSION or
                Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
        )
        recordarUltimoDocumento(it)
    }
}

El número de accesos persistibles que el sistema conserva por aplicación es finito, de modo que conviene liberar los que dejan de usarse en lugar de acumularlos indefinidamente. Un editor con una lista de documentos recientes que nunca poda su colección acabará descubriendo, en el dispositivo de algún usuario intensivo, que los identificadores más antiguos han dejado de funcionar.

flowchart TD
A[Necesito un fichero] --> B[Es de mi aplicacion]
B -->|si| C[Directorio privado sin permisos]
B -->|no| D[Es una imagen o un video que elige el usuario]
D -->|si| E[Photo Picker sin permisos]
D -->|no| F[Es un documento concreto]
F -->|si| G[Marco de acceso a documentos]
F -->|no| H[Necesito enumerar la biblioteca completa]
H -->|si| I[Permisos granulares de medios]
H -->|no| J[Revisa el requisito otra vez]

Permisos granulares y acceso parcial

Cuando la enumeración es realmente necesaria —una aplicación de edición que muestra la biblioteca, un cliente de copia de seguridad, un reproductor de música local— la plataforma dejó de ofrecer un permiso único. La API 33 dividió el antiguo permiso de lectura en tres piezas independientes por tipo de medio, y ese es hoy el máximo de granularidad que puedes declarar.

<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission
    android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<uses-permission
    android:name="android.permission.READ_EXTERNAL_STORAGE"
    android:maxSdkVersion="32" />

La API 34 añadió un tercer estado que rompe la dicotomía habitual: el acceso parcial. El usuario puede responder al diálogo eligiendo fotos concretas en lugar de conceder o denegar, y tu aplicación recibe entonces el permiso de selección visual pero no el de imágenes. Al consultar la colección solo verás lo que la persona eligió, y el conjunto puede ampliarse más tarde.

Manejar ese estado correctamente exige comprobar los dos permisos por separado y ofrecer, cuando el acceso es parcial, una vía visible para gestionar la selección. Una aplicación que trate el acceso parcial como una denegación está descartando al usuario que dijo que sí con condiciones, que es precisamente el usuario más cuidadoso de todos.

Queda el acceso total a todos los archivos, que existe y funciona, y cuya declaración desencadena un proceso de revisión en Google Play donde hay que justificar por qué ninguna de las alternativas anteriores sirve. Los casos aceptados forman una lista corta y muy concreta: gestores de ficheros, herramientas de copia de seguridad y migración, antivirus. Si tu producto no es literalmente uno de esos, la solicitud se rechaza y la publicación se bloquea.

La ruta de fichero era una API sin contrato, y por eso tenía que morir

Merece la pena entender por qué el almacenamiento acotado generó tantísima resistencia entre desarrolladores competentes y de buena fe, porque el diagnóstico se aplica mucho más allá de Android. Una ruta de fichero en un espacio compartido es, técnicamente, un puntero global sin tipo, sin dueño, sin control de acceso y sin invariantes: cualquiera puede escribir en ella, cualquiera puede leerla, nadie sabe quién la creó, nadie garantiza qué contiene y no existe manera de expresar la intención con la que se accede. Es exactamente la misma categoría de construcción que la variable global mutable, y produce exactamente los mismos efectos a escala de sistema operativo: acoplamiento invisible entre componentes que no se conocen, imposibilidad de razonar localmente sobre la seguridad, y una superficie de ataque que crece con el cuadrado del número de participantes. Lo que hizo que durante una década pareciera aceptable no fue una virtud técnica sino una circunstancia histórica: cuando el modelo se diseñó, el almacenamiento externo era una tarjeta extraíble que el usuario podía sacar y leer en un ordenador, de modo que no tenía sentido pretender protegerlo. Cuando esa tarjeta se convirtió en memoria interna soldada que contenía toda la vida documental de mil millones de personas, la premisa desapareció y el modelo quedó en el aire, sostenido únicamente por la enorme cantidad de código escrito contra él. La lección general, que es la que conviene llevarse a cualquier otro sistema, es que una API sin contrato explícito acumula dependencias exactamente igual que una deuda acumula intereses, y que la factura no se paga cuando se descubre el problema sino cuando alguien se atreve a arreglarlo. Los cuatro años de migración dolorosa no fueron el coste del almacenamiento acotado: fueron el coste diferido de haber expuesto durante diez años una interfaz que no expresaba nada. Y el corolario práctico es incómodo para cualquiera que diseñe API: cada vez que expones una ruta, un identificador global o un puntero sin dueño porque es lo más rápido, estás emitiendo deuda a nombre de quien tenga que retirarla dentro de una década.

⚔️ Desmonta tu dependencia del almacenamiento
  1. Enumera todos los accesos a almacenamiento de tu aplicación y clasifícalos en privado, contribución, selección puntual y enumeración real.
  2. Sustituye la selección de imágenes de tu aplicación por el Photo Picker y mide cuántas líneas y cuántos permisos desaparecen.
  3. Elimina de tu manifiesto todo permiso de almacenamiento que ya no haga nada y comprueba que ninguna funcionalidad se rompe.
  4. Implementa el estado de acceso parcial en la pantalla que enumera medios y ofrece una vía visible para ampliar la selección.
  5. Escribe una imagen en la galería con el patrón de dos fases y verifica que no aparece en otras aplicaciones hasta que retiras la marca de pendiente.