Saltar al contenido principal
Android Capacitor Guías

Android scoped storage en apps Capacitor

Explicación del scoped storage de Android en apps Capacitor: qué cambió de Android 10 a 14, qué permisos siguen funcionando, MediaStore, SAF y directorios seguros.

Reglas de scoped storage de Android aplicadas a archivos en una app Capacitor

Android scoped storage limita lo que una app Capacitor puede leer y escribir fuera de sus propias carpetas. Desde Android 10, y de forma completa desde Android 11, una app puede usar libremente sus directorios privados, añadir archivos a colecciones compartidas mediante MediaStore y necesita que el usuario elija cualquier otra cosa a través del Storage Access Framework. Esta guía explica los cambios por versión de Android, qué Capacitor Directory valores siguen funcionando y cómo guardar, importar y exportar archivos de forma compatible en Capacitor 8.

Qué cambió, versión a versión

Android API Cambio relevante para apps con Capacitor
10 29 Se introduce Scoped storage. Las apps pueden desactivarlo con requestLegacyExternalStorage="true" al apuntar a API 29
11 30 Scoped storage obligatorio para apps con target 30+. requestLegacyExternalStorage ignorado. WRITE_EXTERNAL_STORAGE no longer grants anything. MANAGE_EXTERNAL_STORAGE introducido. El selector de árbol no puede elegir raíz de Download ni de volumen
12 31-32 Almacenamiento sin cambios
13 33 READ_EXTERNAL_STORAGE reemplazado por READ_MEDIA_IMAGES, READ_MEDIA_VIDEO, READ_MEDIA_AUDIO. Ningún permiso otorga acceso a documentos de otras apps
14 34 Acceso parcial a fotos con READ_MEDIA_VISUAL_USER_SELECTED
15 y 16 35-36 Sin nuevo modelo de storage; se aplica la política de Play sobre permisos de fotos y video

Capacitor 8 apunta a API 36, por lo que tu app siempre se ejecuta con scoped storage completo en dispositivos modernos. No hay ningún flag para recuperar el comportamiento anterior.

Qué falló en versiones antiguas de Capacitor code

Code escrito para Capacitor 2 o 3, o portado de Cordova, suele fallar silenciosamente en dispositivos nuevos:

  • Filesystem.writeFile({ directory: Directory.Documents }) o Directory.ExternalStorage al “guardar en el teléfono”. En Android 11+, estas rutas apuntan al storage compartido, y las escrituras fallan o el archivo no se puede leer tras reinstalar.
  • Leer rutas arbitrarias como /storage/emulated/0/Download/file.pdf desde una ruta introducida en un ajuste o recibida de un servidor.
  • Solicitar READ_EXTERNAL_STORAGE para listar los documentos del usuario. En Android 13+ el permiso no sirve para archivos que no sean multimedia.
  • Usando Filesystem.requestPermissions() y asumiendo granted means the app can write anywhere.
  • Construir rutas de archivos a partir de un content:// URI devuelto por un picker.

Qué sigue funcionando sin ningún permiso

Los directorios propios de tu app no se ven afectados por el scoped storage y no requieren permisos:

Capacitor Directory Ruta en Android Visible para el usuario Se elimina al desinstalar
Data, Library, LibraryNoCloud /data/data/<package>/files No Sí
Cache /data/data/<package>/cache No Sí
External /storage/emulated/0/Android/data/<package>/files Solo vía USB o algunos gestores de archivos Sí
ExternalCache /storage/emulated/0/Android/data/<package>/cache Igual Sí
Documents Compartido Documents carpeta Sí No
ExternalStorage Raíz del almacenamiento compartido Sí No

Las primeras cuatro filas son seguras. Documents y ExternalStorage son las filas legacy: desde Android 11, tu app solo puede ver allí los archivos que ella misma creó, y tras reinstalarla pierde el acceso incluso a esos. Considéralas no compatibles para nuevos code.

External es útil para archivos grandes (videos offline, mapas) porque puede estar en una partición mayor, y esos archivos no forman parte del Auto Backup. Desde Android 11, otras apps no pueden leer Android/data, por lo que sigue siendo privado en la práctica.

Guardar archivos que el usuario pueda encontrar: MediaStore

Cuando el usuario pulsa “Descargar” en una factura, espera encontrarla en la carpeta Descargas. En Android 10+, las apps pueden insertar archivos en las colecciones compartidas Downloads, Pictures, Movies, Music y Documents mediante MediaStore sin ningún permiso.

@capgo/capacitor-file-sharer envuelve esto en save():

bun add @capgo/capacitor-file-sharer
bunx cap sync
import { FileSharer } from '@capgo/capacitor-file-sharer';
import { Filesystem, Directory } from '@capacitor/filesystem';

const { uri } = await Filesystem.getUri({ path: 'exports/invoice-1042.pdf', directory: Directory.Data });

const result = await FileSharer.save({
  filename: 'invoice-1042.pdf',
  path: uri,
  contentType: 'application/pdf',
  android: {
    saveDirectory: 'downloads',     // 'downloads' | 'pictures' | 'movies' | 'music' | 'documents'
    relativePath: 'MyApp/Invoices', // subfolder, Android 10+
  },
});

console.log(result.uri); // content:// URI of the saved file

En iOS, la misma llamada abre el menú de compartir, donde el usuario elige “Guardar en Archivos”, así que una ruta de code cubre ambas plataformas.

En Android 9 y versiones anteriores, escribir en el almacenamiento compartido aún requiere WRITE_EXTERNAL_STORAGE. Decláralo solo para esas versiones:

<uses-permission
    android:name="android.permission.WRITE_EXTERNAL_STORAGE"
    android:maxSdkVersion="28" />

Capacitor 8 es compatible con Android 7 (API 24) en adelante; si aún distribuyes a dispositivos con Android 7 a 9, mantén esa línea.

Fotos y videos

Para multimedia, mejor usa los flujos de la plataforma en vez de escribir los archivos tú mismo. @capacitor/camera escribe en la galería con saveToGallery: true, y solo necesita permisos de almacenamiento legacy en versiones antiguas de Android. Consulta nuestra guía para tomar y editar fotos en Capacitor.

Leer archivos de usuario: pickers en lugar de permisos

Scoped storage espera que el usuario elija los archivos. El picker de Android devuelve un content:// URI que tu app puede leer aunque no tenga permiso de almacenamiento.

import { CapgoFilePicker } from '@capgo/capacitor-file-picker';
import { Filesystem, Directory } from '@capacitor/filesystem';

const { files } = await CapgoFilePicker.pickFiles({ types: ['application/pdf'], limit: 1 });
const picked = files[0];

// Copy into app storage right away: picker grants can expire
await Filesystem.copy({
  from: picked.path!,
  to: `imports/${picked.name}`,
  toDirectory: Directory.Data,
});

Para fotos y videos, @capgo/capacitor-file-pickerde pickMedia y @capacitor/camerade chooseFromGallery usa el Android Photo Picker en Android 13+, que no requiere ningún permiso.

Evita los permisos READ_MEDIA_*

La política de permisos de fotos y videos de Google Play solo permite READ_MEDIA_IMAGES y READ_MEDIA_VIDEO para apps cuyo propósito central requiere acceso amplio a la galería, como apps de galería o editores de fotos. Todos los demás deben usar el selector y declararlo en Play Console. Si un plugin que usas declara estos permisos, elimínalos del manifiesto fusionado:

<uses-permission
    android:name="android.permission.READ_MEDIA_IMAGES"
    tools:node="remove" />
<uses-permission
    android:name="android.permission.READ_MEDIA_VIDEO"
    tools:node="remove" />

Añadir xmlns:tools="http://schemas.android.com/tools" al <manifest> etiqueta primero.

Acceso a carpetas con el Storage Access Framework

Algunas apps necesitan acceso continuo a una carpeta: una app de notas que sincroniza con una carpeta del usuario, una carpeta de exportación para una app de campo. El Storage Access Framework (SAF) permite al usuario conceder una carpeta con ACTION_OPEN_DOCUMENT_TREE:

const { path } = await CapgoFilePicker.pickDirectory();
// On Android: a content://com.android.externalstorage.documents/tree/... URI

Tres cosas a saber:

  1. El resultado es una URI de árbol, no una ruta. No puedes pasarlo a Filesystem.writeFile. Los archivos internos se crean y leen a través de DocumentsContract o DocumentFile en code nativo.
  2. El acceso no sobrevive a un reinicio a menos que tu app llame a takePersistableUriPermission en la URI. Añade un pequeño método nativo si lo necesitas:
@PluginMethod
public void persist(PluginCall call) {
    Uri uri = Uri.parse(call.getString("uri"));
    getContext().getContentResolver().takePersistableUriPermission(
        uri, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
    call.resolve();
}
  1. No se puede otorgar acceso a algunas carpetas. En Android 11+, el selector rechaza la raíz del almacenamiento interno, la raíz de la Download carpeta, y Android/data y Android/obb. Pide a los usuarios que creen o elijan una subcarpeta.

Los permisos persistentes tienen un límite por app, y el usuario puede revocarlos en los ajustes del sistema o borrando los datos de la app. Comprueba el acceso con getPersistedUriPermissions() al inicio y vuelve a pedirlo cuando haya desaparecido.

Acceso a todos los archivos: MANAGE_EXTERNAL_STORAGE

<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />

Este permiso otorga el antiguo comportamiento de «leer y escribir todo», pero no es un prompt en tiempo de ejecución. El usuario debe activarlo en una pantalla de ajustes dedicada, y Google Play lo revisa manualmente. Los casos de uso aceptados son gestores de archivos, copia de seguridad y restauración, antivirus, gestión de documentos, búsqueda de archivos en el dispositivo y algunos otros donde el acceso amplio es la función principal. Una app para tomar notas, un visor de PDF que abre archivos de Descargas o una app que «solo necesita guardar exportaciones» será rechazada.

Si distribuyes fuera de Play (MDM empresarial, dispositivos kiosk con sideload), es una opción. En caso contrario, diseña en torno a MediaStore y SAF.

Cómo encajan otros plugins

Plugin Impacto de Scoped Storage
@capacitor/filesystem Sin problema con los directorios de la app. Documents y ExternalStorage son legacy en Android 11+. requestPermissions() solo importa para esos directorios en versiones antiguas de Android
@capacitor/camera Usa la cámara del sistema y el Photo Picker, sin permiso de almacenamiento excepto saveToGallery en Android 9 y anteriores
@capacitor/share Comparte a través de un FileProvider. Solo las rutas listadas en res/xml/file_paths.xml puede compartirse
@capgo/capacitor-file-sharer Usa su propio FileProvider y MediaStore para save(), sin configuración necesaria
@capgo/capacitor-downloader Descarga en el almacenamiento externo de la app y luego usa save() para exportar
@capgo/capacitor-file-picker Basado en el selector, sin permisos en Android 13+
@capgo/capacitor-photo-library Su manifiesto añade READ_MEDIA_* para lectura completa de la biblioteca, sujeto a la política de Play anterior. pickMedia() usa el selector del sistema y no requiere permisos, así que si solo usas eso, elimina READ_MEDIA_* con tools:node="remove"

Qué directorio usar

  • Datos de la app, ajustes, bases de datos: Directory.Data
  • Archivos temporales y exportaciones para compartir: Directory.Cache
  • Medios grandes descargables: Directory.External en Android, Directory.LibraryNoCloud en iOS
  • Un archivo que el usuario quiere en Descargas o Imágenes: MediaStore mediante FileSharer.save()
  • Un archivo que el usuario introduce en la app: selector, luego copiar en Directory.Data
  • Una carpeta a la que el usuario quiere que la app siga escribiendo: URI de árbol SAF con permiso persistido

Para la lectura y escritura completas de API, consulta el guía de manejo de archivos de Capacitor.

Migrar una app existente

  1. Busca en tu code por Directory.Documents, Directory.ExternalStorage, /storage/emulated, y requestLegacyExternalStorage.
  2. Mover los archivos de la app a Directory.Data o Directory.External. En el primer inicio de la nueva versión, copia los archivos antiguos que aún puedas leer y luego elimínalos.
  3. Replace “save to device” features with FileSharer.save().
  4. Cambia los inputs de rutas por un selector.
  5. Añadir android:maxSdkVersion a los permisos de almacenamiento heredados y elimina READ_MEDIA_* a menos que lo necesites.
  6. Prueba en dispositivos o emuladores con Android 10, 11, 13 y 15 o 16. El comportamiento cambia justo en esos límites.

preserveLegacyExternalStorage="true" en la <application> tag mantiene el acceso heredado para apps actualizadas desde una versión que usaba requestLegacyExternalStorage, hasta que se reinstale la app. Solo sirve para un paso de migración único, así que no dependas de ello.

Solución de problemas

EACCES (Permission denied) escribir en /storage/emulated/0/...: estás escribiendo directamente en el almacenamiento compartido. Usa MediaStore o un directorio de la app.

Archivo guardado con Directory.Documents se pierde tras reinstalar: la app perdió la propiedad del archivo. Usa MediaStore para archivos de usuario.

FileNotFoundException leyendo un content:// URI después: el permiso temporal expiró. Copia el archivo al seleccionarlo o persiste el permiso para URIs de árbol.

Advertencia de Play Console sobre permisos de fotos y videos: un plugin añadió READ_MEDIA_IMAGES o READ_MEDIA_VIDEO. Elimínalos con tools:node="remove" si no los necesitas.

requestPermissions() devuelve granted, pero las escrituras siguen fallando: en Android 11+ el permiso de almacenamiento legacy se concede, pero no sirve para el almacenamiento compartido.

Mantén sincronizadas las partes nativa y web

Las reglas de almacenamiento son configuración nativa; cualquier cambio en el manifest o los plugins requiere una nueva versión en la tienda, lo que Build de Capgo puede generarse en la nube. La lógica de archivos a su alrededor (rutas, nombres de carpetas, flujos de exportación) es JavaScript que puedes actualizar con actualizaciones Live Update de Capgo.

Live updates para apps Capacitor

Cuando un bug de la capa web está en producción, publica el fix a través de Capgo en lugar de esperar días a la aprobación de la app store. Los usuarios reciben la actualización en segundo plano mientras los cambios nativos siguen el flujo de revisión habitual.

Lo último de nuestro blog

Capgo te da la información clave que necesitas para crear una app móvil realmente profesional.