Offline Architecture
Architecture offline-first pour l'application mobile YaniPay. Accès aux données en cache, détection réseau et dégradation gracieuse pour une expérience utilisateur continue.
Overview
YaniPay adopte une philosophie offline-firstpour son application mobile. Cela signifie que l'application est conçue pour fonctionner même sans connexion réseau, en s'appuyant sur des données en cache et une file d'attente de synchronisation pour les opérations différées.
Dans le contexte fintech, cette approche est essentielle : les utilisateurs doivent pouvoir consulter leur solde, voir leurs dernières transactions et accéder à leurs informations de carte même dans un tunnel de métro ou une zone sans couverture.
Cache Local
SecureStore + AsyncStorage
Sync Queue
Retry automatique
Network Monitor
Détection temps réel
Data Freshness
Indicateurs de fraîcheur
Offline Architecture
L'architecture offline de YaniPay repose sur quatre couches interconnectées : l'application, le cache local, la file de synchronisation et le moniteur réseau.
Cache Strategy
La stratégie de cache définit précisément quelles données sont conservées localement et lesquelles nécessitent une connexion réseau.
Données en cache (disponibles hors ligne)
Profil utilisateur
TTL: 24hSecureStoreNom, email, avatar, préférences
Solde du wallet
TTL: 5minSecureStoreDernier solde connu avec timestamp
50 dernières transactions
TTL: 15minAsyncStorageHistorique récent avec montants et marchands
Liste des cartes
TTL: 1hSecureStoreCartes actives (4 derniers chiffres, statut)
Statut fidélité
TTL: 30minAsyncStoragePoints, niveau, récompenses disponibles
Données NON mises en cache (réseau requis)
Virements et transferts
Opération financière sensible, confirmation serveur requise
Vérification KYC
Upload de documents et vérification en temps réel
Staking / DeFi
Interaction blockchain nécessitant confirmation on-chain
Données sensibles de carte
Numéro complet, CVV : jamais stockés localement
Règles d'invalidation du cache
- Time-based: chaque entrée a un TTL (Time To Live) après lequel elle est considérée périmée
- Event-based: une notification push (nouvelle transaction, changement de solde) invalide les entrées concernées
- User-triggered: le pull-to-refresh force un rafraîchissement complet depuis le serveur
- Background sync: toutes les 5 minutes en arrière-plan si le réseau est disponible
Network Detection
YaniPay utilise @react-native-community/netinfopour détecter en temps réel l'état de la connexion et la qualité du réseau.
import { useEffect, useState, useCallback } from 'react';
import NetInfo, {
NetInfoState,
NetInfoStateType,
} from '@react-native-community/netinfo';
export type ConnectionQuality = 'excellent' | 'good' | 'poor' | 'offline';
export interface NetworkStatus {
isConnected: boolean;
connectionType: NetInfoStateType;
quality: ConnectionQuality;
isInternetReachable: boolean | null;
lastChecked: Date;
}
function assessQuality(state: NetInfoState): ConnectionQuality {
if (!state.isConnected || !state.isInternetReachable) {
return 'offline';
}
if (state.type === 'wifi') {
return 'excellent';
}
if (state.type === 'cellular') {
const details = state.details;
if (details?.cellularGeneration === '4g' ||
details?.cellularGeneration === '5g') {
return 'good';
}
return 'poor';
}
return 'good';
}
export function useNetworkStatus(): NetworkStatus {
const [status, setStatus] = useState<NetworkStatus>({
isConnected: true,
connectionType: NetInfoStateType.unknown,
quality: 'good',
isInternetReachable: null,
lastChecked: new Date(),
});
useEffect(() => {
// Subscribe to network state changes
const unsubscribe = NetInfo.addEventListener((state) => {
setStatus({
isConnected: state.isConnected ?? false,
connectionType: state.type,
quality: assessQuality(state),
isInternetReachable: state.isInternetReachable,
lastChecked: new Date(),
});
});
return () => unsubscribe();
}, []);
return status;
}
// Hook for conditional fetching based on network
export function useOnlineAction() {
const { isConnected, quality } = useNetworkStatus();
const executeWhenOnline = useCallback(
async <T>(
action: () => Promise<T>,
fallback?: T
): Promise<T | undefined> => {
if (!isConnected) {
if (fallback !== undefined) return fallback;
throw new Error('No network connection');
}
return action();
},
[isConnected]
);
return { isConnected, quality, executeWhenOnline };
}Mode offline avec données en cache
Graceful Degradation
Le principe de dégradation gracieuse définit ce qui reste fonctionnel hors ligne et ce qui nécessite une connexion.
Fonctionne hors ligne
- Consulter le solde (dernier connu)
- Voir les 50 dernières transactions
- Afficher les cartes (4 derniers chiffres)
- Consulter le profil utilisateur
- Voir les points de fidélité
- Accéder aux paramètres de l’app
- Générer un QR code statique
- Consulter les reçus en cache
Nécessite le réseau
- Effectuer un virement ou transfert
- Paiement NFC (validation serveur)
- Scanner et valider un QR code
- Commande de nouvelle carte
- Upload documents KYC
- Staking et opérations DeFi
- Modification du PIN
- Export de reçus (génération PDF)
Transactions financières
Implementation
Voici l'implémentation du système de cache offline avec Expo SecureStore pour les données sensibles et AsyncStorage pour les données non sensibles.
import * as SecureStore from 'expo-secure-store';
import AsyncStorage from '@react-native-async-storage/async-storage';
export interface CacheEntry<T> {
data: T;
cachedAt: number; // timestamp
ttl: number; // milliseconds
version: number; // cache version for migration
}
export type CacheStorage = 'secure' | 'async';
const CACHE_VERSION = 1;
export class CacheManager {
// Write to cache
static async set<T>(
key: string,
data: T,
ttl: number,
storage: CacheStorage = 'async'
): Promise<void> {
const entry: CacheEntry<T> = {
data,
cachedAt: Date.now(),
ttl,
version: CACHE_VERSION,
};
const serialized = JSON.stringify(entry);
if (storage === 'secure') {
await SecureStore.setItemAsync(
`cache_${key}`,
serialized,
{
keychainAccessible:
SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
}
);
} else {
await AsyncStorage.setItem(`cache_${key}`, serialized);
}
}
// Read from cache
static async get<T>(
key: string,
storage: CacheStorage = 'async'
): Promise<{ data: T; isStale: boolean; age: number } | null> {
try {
const raw =
storage === 'secure'
? await SecureStore.getItemAsync(`cache_${key}`)
: await AsyncStorage.getItem(`cache_${key}`);
if (!raw) return null;
const entry: CacheEntry<T> = JSON.parse(raw);
// Check version compatibility
if (entry.version !== CACHE_VERSION) {
await this.remove(key, storage);
return null;
}
const age = Date.now() - entry.cachedAt;
const isStale = age > entry.ttl;
return {
data: entry.data,
isStale,
age,
};
} catch {
return null;
}
}
// Remove from cache
static async remove(
key: string,
storage: CacheStorage = 'async'
): Promise<void> {
if (storage === 'secure') {
await SecureStore.deleteItemAsync(`cache_${key}`);
} else {
await AsyncStorage.removeItem(`cache_${key}`);
}
}
// Clear all cache
static async clearAll(): Promise<void> {
const keys = await AsyncStorage.getAllKeys();
const cacheKeys = keys.filter((k) => k.startsWith('cache_'));
await AsyncStorage.multiRemove(cacheKeys);
}
// Get cache stats
static async getStats(): Promise<{
totalEntries: number;
staleEntries: number;
totalSizeBytes: number;
}> {
const keys = await AsyncStorage.getAllKeys();
const cacheKeys = keys.filter((k) => k.startsWith('cache_'));
let staleCount = 0;
let totalSize = 0;
for (const key of cacheKeys) {
const raw = await AsyncStorage.getItem(key);
if (raw) {
totalSize += raw.length * 2; // UTF-16
const entry = JSON.parse(raw);
if (Date.now() - entry.cachedAt > entry.ttl) {
staleCount++;
}
}
}
return {
totalEntries: cacheKeys.length,
staleEntries: staleCount,
totalSizeBytes: totalSize,
};
}
}
// Predefined cache keys and TTLs
export const CACHE_CONFIG = {
USER_PROFILE: { key: 'user_profile', ttl: 24 * 60 * 60 * 1000, storage: 'secure' as CacheStorage },
WALLET_BALANCE: { key: 'wallet_balance', ttl: 5 * 60 * 1000, storage: 'secure' as CacheStorage },
TRANSACTIONS: { key: 'transactions', ttl: 15 * 60 * 1000, storage: 'async' as CacheStorage },
CARD_LIST: { key: 'card_list', ttl: 60 * 60 * 1000, storage: 'secure' as CacheStorage },
LOYALTY_STATUS: { key: 'loyalty_status', ttl: 30 * 60 * 1000, storage: 'async' as CacheStorage },
} as const;import { useEffect, useState, useCallback } from 'react';
import { CacheManager, CacheStorage } from '@/lib/offline/cacheManager';
import { useNetworkStatus } from './useNetworkStatus';
interface UseCachedDataOptions<T> {
cacheKey: string;
ttl: number;
storage?: CacheStorage;
fetcher: () => Promise<T>;
enabled?: boolean;
}
interface UseCachedDataResult<T> {
data: T | null;
isLoading: boolean;
isStale: boolean;
lastUpdated: Date | null;
error: string | null;
refresh: () => Promise<void>;
}
export function useCachedData<T>({
cacheKey,
ttl,
storage = 'async',
fetcher,
enabled = true,
}: UseCachedDataOptions<T>): UseCachedDataResult<T> {
const [data, setData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isStale, setIsStale] = useState(false);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [error, setError] = useState<string | null>(null);
const { isConnected } = useNetworkStatus();
const loadData = useCallback(async () => {
if (!enabled) return;
setIsLoading(true);
setError(null);
// 1. Try cache first
const cached = await CacheManager.get<T>(cacheKey, storage);
if (cached) {
setData(cached.data);
setIsStale(cached.isStale);
setLastUpdated(new Date(Date.now() - cached.age));
// If cache is fresh, we are done
if (!cached.isStale) {
setIsLoading(false);
return;
}
}
// 2. If online, fetch fresh data
if (isConnected) {
try {
const freshData = await fetcher();
setData(freshData);
setIsStale(false);
setLastUpdated(new Date());
// Update cache
await CacheManager.set(cacheKey, freshData, ttl, storage);
} catch (err) {
// If we have cached data, use it with stale flag
if (cached) {
setError('Failed to refresh. Showing cached data.');
} else {
setError(
err instanceof Error ? err.message : 'Failed to load data'
);
}
}
} else if (!cached) {
setError('No cached data available offline');
}
setIsLoading(false);
}, [cacheKey, ttl, storage, fetcher, isConnected, enabled]);
useEffect(() => {
loadData();
}, [loadData]);
return {
data,
isLoading,
isStale,
lastUpdated,
error,
refresh: loadData,
};
}Indicateur de fraîcheur des données
Lorsque les données affichées proviennent du cache et sont potentiellement obsolètes, un bandeau d'avertissement apparaît :