Chapitre 724 minIntermédiaire
Stocker des données sur le téléphone : SecureStore, SQLite et mode hors ligne
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- choisir le bon stockage pour chaque donnée du téléphone ;
- ranger un secret dans SecureStore (trousseau, Keystore) ;
- créer et faire évoluer une base SQLite locale avec des requêtes préparées ;
- construire une file d'attente hors ligne synchronisée au retour du réseau.
4 exercices corrigés1 schéma
Une application mobile range ses données locales à trois endroits selon leur nature : les secrets (jetons, clés) dans le stockage sécurisé du système avec expo-secure-store, les préférences dans un simple stockage clé-valeur, et les données structurées dans une base SQLite embarquée avec expo-sqlite. Ce chapitre explique où ranger quoi, écrit le coffre à jetons d'AppliFrais, crée sa base locale, puis construit le mode hors ligne : une file d'attente des frais saisis sans réseau, envoyée automatiquement au retour de la connexion.
Où ranger quoi
| Stockage | Ce qu'il protège | Ce qu'on y met | Ce qu'on n'y met pas |
|---|---|---|---|
| La mémoire (une variable, l'état React) | Effacée quand l'application est fermée | Le jeton d'accès, les données affichées | Ce qui doit survivre à un redémarrage |
expo-secure-store (trousseau iOS, Keystore Android) | Chiffré par le système, avec une clé matérielle | Le jeton de rafraîchissement, une clé de chiffrement | De gros volumes (quelques kilo-octets au plus par valeur) |
expo-sqlite/kv-store (ou AsyncStorage) | Rien : un fichier en clair dans le dossier de l'application | Des préférences : thème, rappel activé, dernier onglet | Un jeton, un mot de passe, une donnée personnelle sensible |
expo-sqlite (base SQLite) | Rien par défaut : un fichier en clair | Des données structurées : file d'attente, cache, journal | Des secrets |
expo-file-system (fichiers) | Rien par défaut | Des photos, des PDF téléchargés | Des secrets |
SecureStore : le trousseau du téléphone
expo-secure-store range des paires clé-valeur chiffrées :
- sur iOS, dans le trousseau (Keychain), le coffre du système qui garde aussi les mots de passe Wi-Fi ;
- sur Android, chiffrées avec une clé du Keystore, générée et conservée dans une zone matérielle protégée du téléphone, d'où elle ne peut pas être extraite.
npx expo install expo-secure-store
Le coffre à jetons d'AppliFrais, src/lib/jetons.ts, implémente l'interface CoffreJetons attendue par le client d'API :
import * as SecureStore from 'expo-secure-store';
import type { CoffreJetons } from './api';
const CLE = 'applifrais.rafraichissement';
const OPTIONS: SecureStore.SecureStoreOptions = {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY, // iOS : jamais copié vers un autre appareil
};
export const coffreSecureStore: CoffreJetons = {
lireRafraichissement: () => SecureStore.getItemAsync(CLE, OPTIONS),
ecrireRafraichissement: (jeton) => SecureStore.setItemAsync(CLE, jeton, OPTIONS),
effacer: () => SecureStore.deleteItemAsync(CLE, OPTIONS),
};
Trois fonctions suffisent : setItemAsync, getItemAsync (qui renvoie null si la clé n'existe pas) et deleteItemAsync. L'option keychainAccessible règle, sur iOS, quand la valeur est lisible : WHEN_UNLOCKED_THIS_DEVICE_ONLY la rend lisible seulement quand le téléphone est déverrouillé, et l'exclut des sauvegardes restaurées sur un autre appareil.
Les limites à connaître :
- La taille. Certaines versions d'iOS ont refusé des valeurs de plus de 2 048 octets environ. Un jeton tient sans peine ; un objet JSON de données, non.
- La désinstallation. Sur Android, les valeurs disparaissent avec l'application. Sur iOS, le trousseau peut les conserver après une désinstallation : une réinstallation retrouverait le jeton. Au premier lancement, une application soigneuse vérifie un indicateur dans le stockage ordinaire (effacé, lui, à la désinstallation) et vide SecureStore s'il est absent.
- La sauvegarde Android. Le module d'Expo configure la sauvegarde automatique d'Android pour exclure ses valeurs, qui seraient de toute façon indéchiffrables sur un autre téléphone (la clé du Keystore reste dans l'ancien).
- Le Web. SecureStore n'existe pas dans la version web de l'application.
Les préférences : un stockage clé-valeur
Pour une préférence sans enjeu de sécurité, comme le rappel de fin de mois activé ou non, un stockage clé-valeur simple suffit. expo-sqlite en fournit un, compatible avec l'API d'AsyncStorage, la bibliothèque historique de React Native :
import Storage from 'expo-sqlite/kv-store';
export async function memoriserRappels(actif: boolean) {
await Storage.setItem('rappels', actif ? 'oui' : 'non');
}
export async function lireRappels(): Promise<boolean> {
return (await Storage.getItem('rappels')) === 'oui';
}
Les valeurs sont des chaînes : pour un objet, JSON.stringify à l'écriture et JSON.parse à la lecture. @react-native-async-storage/async-storage fait la même chose avec la même API ; tu le croiseras dans beaucoup de projets existants.
SQLite : une vraie base dans l'application
SQLite est un moteur de base de données relationnelle sans serveur : toute la base tient dans un fichier, dans le dossier de l'application. C'est le SGBD le plus déployé au monde, présent dans chaque téléphone Android et iOS. Tu y écris le même SQL que sur MySQL, à quelques différences près (cours SQL).
npx expo install expo-sqlite
La base locale d'AppliFrais, src/lib/db.ts, a deux tables : la file d'attente des frais saisis hors ligne, et un journal des tentatives de déverrouillage biométrique (chapitre 9).
import * as SQLite from 'expo-sqlite';
import type { Frais } from './validation';
export type FraisEnAttente = Frais & { cle: string; idFiche: number; photo: string | null };
let base: SQLite.SQLiteDatabase | null = null;
export async function ouvrirBase(): Promise<SQLite.SQLiteDatabase> {
if (base) return base;
base = await SQLite.openDatabaseAsync('applifrais.db');
await migrer(base);
return base;
}
// PRAGMA user_version : un entier rangé dans le fichier, qui dit quelle version du schéma il contient.
async function migrer(db: SQLite.SQLiteDatabase) {
const ligne = await db.getFirstAsync<{ user_version: number }>('PRAGMA user_version');
const version = ligne?.user_version ?? 0;
if (version < 1) {
await db.execAsync(`
CREATE TABLE IF NOT EXISTS frais_en_attente (
cle TEXT PRIMARY KEY,
id_fiche INTEGER NOT NULL,
date TEXT NOT NULL,
libelle TEXT NOT NULL,
montant REAL NOT NULL CHECK (montant > 0),
photo TEXT,
cree_le TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS journal (
id INTEGER PRIMARY KEY AUTOINCREMENT,
horodatage TEXT NOT NULL DEFAULT (datetime('now')),
type TEXT NOT NULL,
resultat TEXT NOT NULL,
message TEXT
);
PRAGMA user_version = 1;
`);
}
}
export async function mettreEnAttente(idFiche: number, frais: Frais, cle: string, photo: string | null) {
const db = await ouvrirBase();
await db.runAsync(
'INSERT INTO frais_en_attente (cle, id_fiche, date, libelle, montant, photo) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (cle) DO NOTHING',
[cle, idFiche, frais.date, frais.libelle, frais.montant, photo],
);
}
export async function lireEnAttente(): Promise<FraisEnAttente[]> {
const db = await ouvrirBase();
return db.getAllAsync<FraisEnAttente>(
'SELECT cle, id_fiche AS idFiche, date, libelle, montant, photo FROM frais_en_attente ORDER BY rowid',
);
}
export async function retirerDeLAttente(cle: string) {
const db = await ouvrirBase();
await db.runAsync('DELETE FROM frais_en_attente WHERE cle = ?', [cle]);
}
export async function journaliser(type: string, resultat: 'Succes' | 'Echec', message: string) {
const db = await ouvrirBase();
await db.runAsync('INSERT INTO journal (type, resultat, message) VALUES (?, ?, ?)', [type, resultat, message]);
}
export async function viderDonneesLocales() {
const db = await ouvrirBase();
await db.execAsync('DELETE FROM frais_en_attente; DELETE FROM journal;');
}
Les fonctions d'expo-sqlite :
| Fonction | Usage | Renvoie |
|---|---|---|
openDatabaseAsync(nom) | Ouvre (ou crée) le fichier de base | La base |
execAsync(sql) | Exécute une ou plusieurs instructions sans paramètres | Rien |
runAsync(sql, params) | INSERT, UPDATE, DELETE avec paramètres | { lastInsertRowId, changes } |
getFirstAsync(sql, params) | La première ligne d'un SELECT | Un objet ou null |
getAllAsync(sql, params) | Toutes les lignes d'un SELECT | Un tableau d'objets |
withTransactionAsync(fn) | Exécute plusieurs requêtes dans une transaction | Rien |
Ce qu'il faut retenir de ce fichier :
- Les requêtes sont préparées : les valeurs passent par des
?et un tableau de paramètres, jamais par concaténation. Une base locale s'injecte comme une base serveur, et le sujet de cybersécurité SLAM de 2024 (alors E6, aujourd'hui E7) demandait justement d'écrire une insertion paramétrée dans une table de journal SQLite sur Android (requêtes préparées).execAsyncne prend pas de paramètres : il est réservé au SQL fixe, comme le schéma. - Le schéma est versionné.
PRAGMA user_versionest un entier stocké dans le fichier de base, 0 à la création. La migration crée les tables puis passe la version à 1. Pour ajouter une colonne dans une prochaine version de l'application, tu ajouteras un blocif (version < 2) { … ALTER TABLE … ; PRAGMA user_version = 2; }. Les téléphones qui ont déjà la version 1 n'exécuteront que ce bloc, sans perdre leurs données. ON CONFLICT (cle) DO NOTHINGignore un frais déjà en attente avec la même clé d'idempotence. N'utilise pasINSERT OR IGNOREà la place : en SQLite,OR IGNOREignore aussi les violations deCHECKet deNOT NULL. Un frais à montant nul serait silencieusement jeté au lieu de provoquer une erreur.ORDER BY rowidrend les frais dans l'ordre d'insertion.datetime('now')n'a qu'une précision d'une seconde, et deux frais saisis dans la même seconde n'auraient pas d'ordre garanti parcree_le. Ces dates sont d'ailleurs en UTC.- SQLite est souple sur les types : une colonne
REALaccepte une chaîne si on la lui donne. Les contraintesNOT NULLetCHECKet la validation de l'application compensent.
Le mode hors ligne : une file d'attente
Lucas saisit ses frais dans le train, en zone blanche. Sans précaution, chaque enregistrement échoue avec une ErreurReseau, et il doit tout ressaisir plus tard. L'application adopte une stratégie simple et robuste : la file d'attente.
- Lucas enregistre un frais. L'application tente l'envoi à l'API.
- En cas d'
ErreurReseau, elle range le frais dans la tablefrais_en_attente, avec sa clé d'idempotence, et revient au détail de la fiche comme si de rien n'était. - Quand le réseau revient, ou quand l'application repasse au premier plan, elle envoie les frais en attente, dans l'ordre, chacun avec sa clé d'origine.
- Chaque frais accepté (ou définitivement refusé) est retiré de la file ; au premier échec réseau, l'envoi s'arrête et reprendra plus tard.
La synchronisation, src/lib/synchro.ts, ne dépend ni de SQLite ni du réseau : elle reçoit le client d'API et un « dépôt » qui sait lire et retirer les frais en attente. On peut ainsi la tester avec des doublures (chapitre 11).
import { ErreurApi, type Api } from './api';
import type { FraisEnAttente } from './db';
type Depot = {
lire: () => Promise<FraisEnAttente[]>;
retirer: (cle: string) => Promise<void>;
};
type Bilan = { envoyes: number; refuses: { frais: FraisEnAttente; raison: string }[] };
// Renvoyer la même requête donnerait le même refus : inutile de réessayer.
const REFUS_DEFINITIFS = [400, 404, 409, 413, 415, 422];
// Envoie les frais saisis hors ligne, dans l'ordre, avec leur clé d'idempotence d'origine.
export async function synchroniser(
api: Pick<Api, 'ajouterFrais' | 'envoyerJustificatif'>,
depot: Depot,
formulaire: (uri: string) => FormData,
): Promise<Bilan> {
const bilan: Bilan = { envoyes: 0, refuses: [] };
for (const f of await depot.lire()) {
try {
const cree = await api.ajouterFrais(f.idFiche, { date: f.date, libelle: f.libelle, montant: f.montant }, f.cle);
if (f.photo) await api.envoyerJustificatif(cree.id, formulaire(f.photo));
await depot.retirer(f.cle);
bilan.envoyes++;
} catch (e) {
const definitif = e instanceof ErreurApi && REFUS_DEFINITIFS.includes(e.status)
&& !e.type.endsWith('/requete-en-cours'); // 409 de la clé d'idempotence : la 1re requête n'a pas fini
if (!definitif) break; // réseau coupé, session expirée, serveur en panne : on réessaiera plus tard
await depot.retirer(f.cle);
bilan.refuses.push({ frais: f, raison: (e as Error).message }); // l'écran les affiche à l'utilisateur
}
}
return bilan;
}
La distinction entre les erreurs est le cœur de l'algorithme :
| Erreur | Signification | Que faire du frais |
|---|---|---|
ErreurReseau | Toujours pas de réseau, ou délai dépassé | Le garder, arrêter, réessayer plus tard |
401 après échec du rafraîchissement | Session expirée | Le garder : il partira après la reconnexion |
429, 500, 503 | Serveur surchargé ou en panne | Le garder, réessayer plus tard |
400, 404, 409, 422 | Refus définitif : données invalides, fiche disparue ou clôturée | Le retirer, et prévenir Lucas pour qu'il corrige |
413, 415 | Photo refusée : trop lourde ou d'un type non accepté (le frais, lui, est créé) | Le retirer, et prévenir Lucas de joindre une autre photo |
409 de type requete-en-cours | La première tentative avec la même clé est encore en traitement (chapitre 15) | Le garder : le renvoi suivant recevra la réponse d'origine |
Réessayer un refus définitif ne servirait à rien : la file resterait bloquée sur ce frais pour toujours. Le jeter en silence ferait perdre une dépense à Lucas : le bilan renvoie donc la liste des refus, que l'écran affiche. Et la clé d'idempotence couvre le cas le plus sournois : si le frais est arrivé à l'API mais que la réponse s'est perdue, le renvoi ne crée pas de doublon.
Détecter le retour du réseau
Le module expo-network donne l'état de la connexion, avec un hook qui se met à jour tout seul :
npx expo install expo-network
Le hook useSynchroAuto, dans src/lib/useSynchroAuto.ts, lance la synchronisation quand Internet redevient joignable et chaque fois que l'application repasse au premier plan :
import * as Network from 'expo-network';
import { useEffect } from 'react';
import { AppState } from 'react-native';
import { formulaireJustificatif } from './appareil';
import { api } from './client';
import { lireEnAttente, retirerDeLAttente } from './db';
import { synchroniser } from './synchro';
// Envoie la file d'attente quand le réseau revient et quand l'application repasse au premier plan.
export function useSynchroAuto(actif: boolean) {
const reseau = Network.useNetworkState();
useEffect(() => {
if (!actif || !reseau.isInternetReachable) return;
const envoyer = () =>
synchroniser(api, { lire: lireEnAttente, retirer: retirerDeLAttente }, formulaireJustificatif).catch(() => undefined);
envoyer();
const abonnement = AppState.addEventListener('change', (etat) => {
if (etat === 'active') envoyer();
});
return () => abonnement.remove();
}, [actif, reseau.isInternetReachable]);
}
Il est appelé dans le composant Navigation du layout racine, avant le return null du chargement (un hook ne s'appelle jamais après un return conditionnel) :
const { etat } = useSession();
useSynchroAuto(etat === 'connecte'); // envoie les frais saisis hors ligne dès que possible
isInternetReachable est plus fiable que isConnected : un téléphone peut être connecté à un Wi-Fi d'hôtel qui attend une page de connexion, sans accès réel à Internet. AppState signale les passages entre premier plan ('active') et arrière-plan ('background'). Le profil affiche en plus le nombre de frais en attente et un bouton « Envoyer maintenant » qui appelle la même fonction.
Effacer les données à la déconnexion
Le téléphone d'un visiteur peut être perdu, prêté, ou remis à un collègue. À la déconnexion, l'application efface tout ce qu'elle a stocké : le jeton dans SecureStore (fait par api.deconnexion), et les tables locales.
async function seDeconnecter() {
await viderDonneesLocales(); // rien ne doit rester sur un téléphone prêté ou perdu
await deconnexion();
}
Avant d'effacer une file d'attente non vide, une application soignée prévient : « 2 frais n'ont pas encore été envoyés. Te déconnecter les supprimera. » C'est aussi une application concrète du principe de minimisation du RGPD : ne garder sur l'appareil que ce qui est nécessaire, et pas plus longtemps que nécessaire (les principes du RGPD).
Exercice 1 sur 4 Ranger chaque donnée Réussi À revoir
Pour chaque donnée d'AppliFrais Mobile, choisis le stockage : mémoire, SecureStore, kv-store, SQLite ou fichier.
- Le jeton d'accès de 15 minutes.
- Le jeton de rafraîchissement.
- Le choix « rappel de fin de mois activé ».
- Les 3 frais saisis dans le train.
- La photo du ticket de parking prise hors ligne.
- Le mot de passe de Lucas, « pour ne plus avoir à le taper ».
Voir la réponseMasquer la réponse de l'exercice 1
- Mémoire : il ne sert que pendant la session, et l'application en redemande un au démarrage.
- SecureStore : c'est un secret de longue durée.
- kv-store : une préférence sans enjeu de sécurité.
- SQLite : des données structurées, à envoyer dans l'ordre (table
frais_en_attente). - Fichier : la photo reste dans le dossier où l'appareil photo l'a enregistrée, et SQLite garde seulement son adresse (
photo). - Nulle part. On ne stocke pas le mot de passe : le jeton de rafraîchissement est fait pour éviter de le redemander. Pour un déverrouillage rapide, on utilise la biométrie (chapitre 9).
Tu avais trouvé ?
Exercice 2 sur 4 Interroger le journal local Réussi À revoir
La table journal contient les lignes suivantes :
| id | horodatage | type | resultat | message |
|---|---|---|---|---|
| 1 | 2026-11-02 07:58:10 | LoginBiometrie | Echec | user_cancel |
| 2 | 2026-11-02 07:58:31 | LoginBiometrie | Echec | user_cancel |
| 3 | 2026-11-02 08:01:02 | LoginBiometrie | Echec | lockout |
| 4 | 2026-11-02 08:03:44 | LoginBiometrie | Succes | OK |
- Écris la requête qui compte les échecs par message, du plus fréquent au moins fréquent, et donne son résultat.
- Écris la fonction TypeScript qui renvoie ce résultat avec
expo-sqlite.
Voir la réponseMasquer la réponse de l'exercice 2
-
SELECT message, COUNT(*) AS nb FROM journal WHERE resultat = 'Echec' GROUP BY message ORDER BY nb DESC;message nb user_cancel 2 lockout 1 -
export async function echecsParMessage() { const db = await ouvrirBase(); return db.getAllAsync<{ message: string; nb: number }>( "SELECT message, COUNT(*) AS nb FROM journal WHERE resultat = 'Echec' GROUP BY message ORDER BY nb DESC", ); }La requête n'a aucune valeur variable : pas de paramètre nécessaire. Si l'on filtrait sur une date choisie par l'utilisateur, elle passerait par un
?.
Tu avais trouvé ?
Exercice 3 sur 4 Le frais jeté en silence Réussi À revoir
Un développeur a écrit INSERT OR IGNORE INTO frais_en_attente (…) VALUES (…) pour ne pas mettre deux fois le même frais en attente. Un bogue de l'écran envoie un montant de 0. Que se passe-t-il ? Et avec ON CONFLICT (cle) DO NOTHING ?
Voir la réponseMasquer la réponse de l'exercice 3
Avec INSERT OR IGNORE, la violation de la contrainte CHECK (montant > 0) est ignorée comme un doublon : aucune ligne n'est insérée, aucune erreur n'est levée, et l'application croit le frais en attente. Il est perdu sans que personne le sache. Avec ON CONFLICT (cle) DO NOTHING, seul le conflit sur la clé primaire est ignoré : le montant nul provoque l'erreur « CHECK constraint failed: montant > 0 », que l'application peut signaler. Un bogue visible vaut mieux qu'une perte silencieuse.
Tu avais trouvé ?
Exercice 4 sur 4 Synchroniser dans le bon ordre Réussi À revoir
La file contient trois frais A, B et C. L'envoi de A réussit ; B reçoit 422 (libellé trop court, la règle de l'API a changé) ; C n'a pas encore été traité. Que contiennent la file et le bilan après synchroniser si C réussit ? Et si, au lieu du 422, B avait reçu une ErreurReseau ?
Voir la réponseMasquer la réponse de l'exercice 4
- Avec le
422: le refus est définitif, B est retiré de la file et ajouté àrefuses, puis C est envoyé. File vide ; bilan{ envoyes: 2, refuses: [B] }. L'écran affiche B à Lucas, qui pourra le ressaisir. - Avec une
ErreurReseausur B : la boucle s'arrête (break). A a été retiré ; B et C restent dans la file, dans cet ordre ; bilan{ envoyes: 1, refuses: [] }. Ils partiront à la prochaine synchronisation, avec leurs clés d'origine.
Tu avais trouvé ?