Saltar al contenido
Todas las entradas

Copias de seguridad de datos local-first en Android en 2026: exportación SAF, Auto Backup y un flujo de restauración en el que puedes confiar

Cómo las apps local-first en Android respaldan los datos del usuario sin un servidor: exportación/importación con SAF, Auto Backup for App Data y una restauración que verifica antes de sobrescribir.

MFKAPPS 5 min de lectura

«Sin servidor» es toda la propuesta de una app local-first. También es la razón por la que un restablecimiento de fábrica, un teléfono perdido o un cambio de dispositivo pueden borrar meses de historial de presupuesto en Granyn sin nada de donde restaurar. Si no hay nube, la copia de seguridad no puede ser un añadido de última hora — tiene que ser una función, construida con el mismo cuidado que el modelo de datos que protege.

Hay dos mecanismos en Android que importan aquí, y resuelven problemas distintos. Ninguno sustituye al otro.

Auto Backup for App Data: automático, pero opaco

Android respalda los archivos de tu app en el Google Drive del usuario de forma automática y gratuita, si lo activas:

<!-- AndroidManifest.xml -->
<application
    android:allowBackup="true"
    android:fullBackupContent="@xml/backup_rules"
    ...>
<!-- res/xml/backup_rules.xml -->
<full-backup-content>
    <include domain="database" path="granyn.db" />
    <exclude domain="database" path="granyn.db-wal" />
    <exclude domain="sharedpref" path="device_keys.xml" />
</full-backup-content>

Esto es genuinamente útil — es la red de seguridad que salva a quien nunca abre una pantalla de ajustes. Pero tiene límites reales con los que cuento en mi diseño, en lugar de pelear contra ellos:

  • 25 MB en total, truncado silenciosamente si te pasas. Una base de datos Room con años de historial de transacciones más capturas de pantalla puede alcanzar ese límite más rápido de lo que esperarías.
  • Solo se restaura automáticamente durante el flujo de configuración inicial de un dispositivo nuevo con la misma cuenta de Google. Un usuario que reinstala la app en el mismo teléfono no la recupera de esta forma.
  • Es opaco. No hay ninguna confirmación dentro de la app de qué se respaldó ni cuándo. No puedo mostrarle al usuario «última copia hace 3 días», porque el sistema operativo no me lo dice.

Buen valor por defecto, mala estrategia principal. Es la copia de seguridad para quienes nunca piensan en copias de seguridad — no la que le señalaría a un usuario preocupado.

Exportación explícita: la que el usuario realmente controla

El mecanismo en el que realmente confío es una exportación/importación sencilla que el usuario activa, escribiendo un archivo donde él elija a través del Storage Access Framework:

@Serializable
data class GranynExport(
    val schemaVersion: Int = 2,
    val exportedAt: Long,
    val accounts: List<AccountDto>,
    val transactions: List<TransactionDto>,
)

suspend fun exportTo(context: Context, uri: Uri) {
    val export = GranynExport(
        exportedAt = clock.nowMillis(),
        accounts = accountDao.getAll().map { it.toDto() },
        transactions = transactionDao.getAll().map { it.toDto() },
    )
    context.contentResolver.openOutputStream(uri)?.use { out ->
        out.write(Json.encodeToString(export).toByteArray())
    }
}

Se activa desde ACTION_CREATE_DOCUMENT, así que el usuario elige el destino — almacenamiento del dispositivo, una unidad USB, la carpeta en la nube que ya sincroniza. Ningún permiso más allá del selector puntual, y el archivo es JSON plano e inspeccionable. Ese último detalle importa más de lo que parece: un usuario que puede abrir la copia de seguridad en un editor de texto y ver los nombres de sus propias cuentas confía en ella de una forma que un blob .bak opaco nunca llega a ganarse.

schemaVersion es el campo que salva a mi yo futuro. El modelo de datos de Granyn ya ha cambiado una vez desde el lanzamiento — un campo nuevo significa que subo la versión y el importador sabe qué forma esperar, en lugar de adivinarla a partir de lo que encuentra.

La restauración tiene que ganarse el derecho a sobrescribir cualquier cosa

La importación es donde una función de copia de seguridad realmente se pone a prueba, y es la parte en la que vale la pena ser paranoico. El modo de fallo no es «el archivo está corrupto» — es «el archivo es válido, pero una escritura parcial deja al usuario con menos datos de los que tenía al empezar». Las reglas a las que someto la restauración:

  1. Analizar y validar antes de tocar la base de datos activa. Comprobar schemaVersion, comprobar que el archivo no esté vacío, comprobar que los IDs referenciados sean internamente consistentes.
  2. Mostrar un resumen antes de confirmar. «12 cuentas, 340 transacciones, copia de seguridad del 24 de julio» — una cifra que el usuario puede contrastar con lo que recuerda, antes de que se sobrescriba nada.
  3. Envolver la escritura en una única transacción de Room. O se completa toda la importación o no se completa nada; un fallo a mitad de la importación no puede dejar la base de datos en un estado mezclado.
suspend fun restoreFrom(export: GranynExport) = db.withTransaction {
    require(export.schemaVersion <= CURRENT_SCHEMA_VERSION) {
        "Backup was made with a newer app version"
    }
    accountDao.deleteAll()
    transactionDao.deleteAll()
    accountDao.insertAll(export.accounts.map { it.toEntity() })
    transactionDao.insertAll(export.transactions.map { it.toEntity() })
}

db.withTransaction es quien hace el verdadero trabajo de seguridad — si insertAll lanza una excepción a mitad de camino, Room revierte todo el bloque y los datos existentes del usuario quedan intactos. Sin esto, una restauración que falla a medio camino es peor que no tener función de restauración.

Trátala como una función central, no como una fila de ajustes

Es tentador lanzar la exportación/importación como un único botón enterrado en Ajustes y darlo por terminado. Para una app local-first, ese botón es el plan de recuperación ante desastres — no hay ninguna copia de seguridad del lado del servidor cubriendo silenciosamente un fallo en él. Pruebo el flujo de restauración con la misma seriedad que el onboarding: archivos exportados reales, un teléfono bloqueado y reiniciado, una instalación limpia. Si el botón de exportar está roto, nadie se entera hasta que más lo necesita, que es exactamente el peor momento para descubrirlo.

Nada de esto es ingeniería complicada. Es del tipo aburrido que solo da sus frutos el día que un usuario realmente lo necesita — que, para una app local-first, es la razón entera de que exista.