Aller au contenu
Tous les articles

Sauvegarder des données local-first sur Android en 2026 : export SAF, Auto Backup, et un flux de restauration fiable

Comment les applications Android local-first sauvegardent les données utilisateur sans serveur : export/import SAF, Auto Backup for App Data, et une restauration qui vérifie avant d'écraser.

MFKAPPS 5 min de lecture

« Pas de serveur » est tout l’argument de vente d’une application local-first. C’est aussi la raison pour laquelle une réinitialisation d’usine, un téléphone perdu ou un changement d’appareil peuvent effacer des mois d’historique budgétaire dans Granyn sans rien à restaurer. S’il n’y a pas de cloud, la sauvegarde ne peut pas être une réflexion après coup — elle doit être une fonctionnalité à part entière, construite avec le même soin que le modèle de données qu’elle protège.

Deux mécanismes comptent ici sur Android, et ils résolvent des problèmes différents. Aucun ne remplace l’autre.

Auto Backup for App Data : automatique, mais opaque

Android sauvegarde automatiquement les fichiers de votre application sur le Google Drive de l’utilisateur, gratuitement, si vous l’activez :

<!-- 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>

C’est vraiment utile — c’est le filet de sécurité qui sauve quelqu’un qui n’ouvre jamais un écran de paramètres. Mais cela a de vraies limites, avec lesquelles je compose plutôt que je ne les combats :

  • 25 Mo au total, tronqué silencieusement en cas de dépassement. Une base de données Room avec des années d’historique de transactions plus des captures d’écran peut atteindre cette limite plus vite qu’on ne le pense.
  • Elle ne se restaure automatiquement que pendant le flux de configuration initiale d’un nouvel appareil connecté au même compte Google. Un utilisateur qui réinstalle l’application sur le même téléphone ne récupère rien de cette manière.
  • C’est opaque. Il n’y a aucune confirmation dans l’application de ce qui a été sauvegardé, ni quand. Je ne peux pas afficher « dernière sauvegarde il y a 3 jours », parce que l’OS ne me le dit pas.

Bon réglage par défaut, mauvaise stratégie principale. C’est la sauvegarde pour les gens qui ne pensent jamais aux sauvegardes — pas celle vers laquelle j’orienterais un utilisateur inquiet.

L’export explicite : celui que l’utilisateur contrôle vraiment

Le mécanisme auquel je fais vraiment confiance est un simple export/import déclenché par l’utilisateur, qui écrit un fichier là où il le choisit via le 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())
    }
}

Déclenché depuis ACTION_CREATE_DOCUMENT, ce qui laisse l’utilisateur choisir la destination — stockage de l’appareil, clé USB, n’importe quel dossier cloud déjà synchronisé. Aucune permission au-delà du sélecteur ponctuel, et le fichier est du JSON simple et inspectable. Ce dernier point compte plus qu’il n’y paraît : un utilisateur capable d’ouvrir la sauvegarde dans un éditeur de texte et d’y voir ses propres noms de comptes lui fait confiance d’une manière qu’un blob .bak opaque ne gagnera jamais.

schemaVersion est le champ qui sauve le moi du futur. Le modèle de données de Granyn a déjà changé une fois depuis le lancement — un nouveau champ signifie que j’incrémente la version, et l’importeur sait alors à quelle forme s’attendre, au lieu de deviner à partir de ce qui est présent.

La restauration doit mériter le droit d’écraser quoi que ce soit

C’est à l’import qu’une fonctionnalité de sauvegarde est réellement mise à l’épreuve, et c’est la partie qui mérite d’être paranoïaque. Le mode d’échec n’est pas « le fichier est corrompu » — c’est « le fichier est valide, mais une écriture partielle laisse l’utilisateur avec moins de données qu’au départ ». Les règles auxquelles je soumets la restauration :

  1. Analyser et valider avant de toucher à la base de données active. Vérifier schemaVersion, vérifier que le fichier n’est pas vide, vérifier que les identifiants référencés sont cohérents entre eux.
  2. Afficher un résumé avant de valider. « 12 comptes, 340 transactions, sauvegardé le 24 juillet » — un chiffre que l’utilisateur peut vérifier par rapport à ce dont il se souvient, avant que quoi que ce soit ne soit écrasé.
  3. Encapsuler l’écriture dans une seule transaction Room. Soit tout l’import passe, soit rien n’est appliqué ; un plantage en cours d’import ne peut pas laisser la base de données dans un état bâtard.
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 fait le vrai travail de sécurité — si insertAll lève une exception en cours de route, Room annule tout le bloc et les données existantes de l’utilisateur restent intactes. Sans cela, une restauration qui échoue à mi-chemin est pire que l’absence totale de fonctionnalité de restauration.

Traiter ça comme une fonctionnalité centrale, pas une ligne de paramètres

Il est tentant de livrer l’export/import comme un simple bouton enfoui dans les Paramètres et de considérer que c’est réglé. Pour une application local-first, ce bouton est le plan de reprise après sinistre — il n’y a aucune sauvegarde côté serveur pour discrètement compenser un bug dedans. Je teste le chemin de restauration avec le même sérieux que l’onboarding : de vrais fichiers exportés, un téléphone verrouillé puis redémarré, une installation neuve. Si le bouton d’export est cassé, personne ne le découvre avant d’en avoir le plus besoin — exactement le pire moment pour le découvrir.

Rien de tout cela n’est de l’ingénierie compliquée. C’est le genre ennuyeux, qui ne paie que le jour où un utilisateur en a vraiment besoin — ce qui, pour une application local-first, est la seule raison d’être de cette fonctionnalité.