Architecture Android¶
⬇️ Télécharger cette page en Markdown
Pattern MVVM¶
┌─────────────┐ StateFlow ┌──────────────┐ Room/Retrofit ┌────────────┐
│ Screen │ ◄───────────── │ ViewModel │ ◄────────────────── │ Repository │
│ (Compose) │ UiState/events │ │ │ │
└─────────────┘ └──────────────┘ └────────────┘
│ │
viewModelScope ┌─────┴──────┐
coroutines │ Room DB │
│ Retrofit │
└────────────┘
Règle : les Screens ne lisent que des StateFlow<UiState>. Toute logique métier reste dans le ViewModel ou le Repository.
Architecture offline-first¶
Source unique de vérité : Room¶
Tout ce qui s'affiche vient de Room. L'API ne modifie jamais directement l'UI.
Statuts de synchronisation¶
Synchronisation¶
Deux flux de sync indépendants¶
| Flux | Fonction | Déclencheur | Filtre |
|---|---|---|---|
| Géographique | syncIncidentsFromApi() |
GPS obtenu (HomeViewModel) | Commune courante (mairieId + 10 km) |
| Utilisateur | syncUserIncidentsFromApi() |
Ouverture "Mes incidents" | Tous les incidents du device_id |
Règle de coexistence : syncIncidentsFromApi() préserve le deviceId existant lors d'un UPDATE — évite que le flux géographique efface le marquage utilisateur posé par le flux utilisateur.
Cycle de vie d'un incident soumis¶
Résistance au kill process (v2.9.1)¶
Au démarrage de chaque doWork(), le worker remet tous les incidents bloqués en SYNCING à PENDING :
// SyncIncidentsWorker.doWork() — avant la boucle principale
incidentDao.getIncidentsByStatus(SyncStatus.SYNCING)
.forEach { incidentDao.updateSyncStatus(it.id, SyncStatus.PENDING) }
Garantit que les incidents interrompus par un kill OS (OOM, force stop) sont correctement retentés.
Atomicité de la sync géographique (v2.9.1)¶
deleteRemoteIncidents() + le forEach insert/update sont enveloppés dans database.withTransaction {}. Un process death entre les deux ne laisse plus la carte vide.
Prévention de la double transaction (v2.9.1)¶
Dans la boucle du worker, chaque incident est re-lu depuis la DB avant traitement. Si son statut est déjà SYNCING (mis par retryIncident() concurrent), il est skippé :
val current = incidentDao.getIncidentById(incident.id) ?: continue
if (current.syncStatus == SyncStatus.SYNCING) continue
Couche données¶
Room — tables principales¶
| Table | Entité | Clé | Usage |
|---|---|---|---|
incidents |
IncidentEntity |
id (auto) |
Signalements utilisateur + geo-sync |
photos_incident |
PhotoEntity |
id (auto) |
Photos/vidéos liées aux incidents |
incident_types |
IncidentTypeEntity |
(id, codeInsee) |
Cache des types par commune |
informations_officielles |
InfoOfficielleEntity |
id |
Publications mairie |
alertes |
AlerteEntity |
id |
Alertes audio |
bug_reports |
BugReportEntity |
id |
Rapports de bugs locaux |
Chiffrement DB : SQLCipher 4.12.0, clé dérivée du fingerprint SHA-256 de l'appareil.
Chiffrement préférences (v2.9.1) : ProfileStorageManager utilise EncryptedSharedPreferences AES256-GCM (security-crypto:1.1.0-alpha06) pour les données personnelles (nom, email, téléphone, préférences carte).
Retrofit — client HTTP¶
| Config | Valeur |
|---|---|
| Base URL | https://urbafix.fr/ |
| Timeouts | 30s connect / read / write |
| Certificate pinning | SHA-256 (ANSSI/RGS) |
| Logging | BODY en debug, NONE en prod |
| Auth | Intercepteur X-Fingerprint + X-API-Key |
HomeViewModel — gestion carte¶
Filtre des incidents affichés¶
incidents.filter { incident ->
// 1. Uniquement les incidents confirmés par le serveur
if (incident.syncStatus != SyncStatus.SYNCED) return@filter false
// 2. Commune courante
if (state.currentMairieId != null && incident.mairieId != state.currentMairieId)
return@filter false
// 3. Rayon 10 km autour du centre de la carte
val distance = GeoUtils.calculateDistance(
state.mapCenter.latitude, state.mapCenter.longitude,
incident.latitude, incident.longitude
)
distance <= FILTER_RADIUS_METERS // 10 000 m
}
Rationale : filtrer par syncStatus == SYNCED (plutôt que deviceId == "") permet aux incidents de l'utilisateur d'apparaître sur la carte publique une fois confirmés, quelle que soit leur origine de sync.
Prévention du double sync au démarrage¶
HomeViewModel.init() lance centerMapOnCurrentLocation() → syncIncidents(). HomeScreen.LaunchedEffect(Unit) appelle recenterMap() qui vérifie isLocating avant de relancer le cycle :
fun recenterMap() {
val state = _uiState.value
if (state.isInitialized || state.isLocating) return // garde
centerMapOnCurrentLocation()
}
MyReportsViewModel — gestion des erreurs¶
Protection contre les retries concurrents¶
private val _inFlightRetries = mutableSetOf<Long>()
fun retryIncident(incidentId: Long) {
if (incidentId in _inFlightRetries) return // déjà en vol
_inFlightRetries.add(incidentId) // ajout synchrone (main thread)
viewModelScope.launch {
try { /* send */ }
finally { _inFlightRetries.remove(incidentId) }
}
}
Le Set est manipulé depuis le main thread (appels UI) avant le launch, garantissant l'atomicité sans mutex.
Infrastructure serveur¶
Architecture de déploiement¶
Internet
│ HTTPS 443
▼
Reverse Proxy (container) ← SSL termination, routing par Host
│ HTTP
├──► monquartier_web:80 ← nginx + PHP-FPM (urbafix.fr)
├──► urbanappslab-nginx ← autres apps
└──► ...
monquartier_web (nginx)
root /var/www/html/public ← PHP + assets
location ^~ /uploads/ {
alias /var/www/html/uploads/; ← photos incidents (hors public/)
}
Uploads photos¶
Les photos sont sauvegardées dans /var/www/html/uploads/incidents/ (hors du document root public/). L'accès HTTP est géré par une location ^~ /uploads/ avec alias qui prend la priorité sur les locations regex ~* \.(jpg|...)$.
| Chemin disque | URL publique |
|---|---|
/var/www/html/uploads/incidents/{filename}.jpg |
https://urbafix.fr/uploads/incidents/{filename}.jpg |
/var/www/html/uploads/videos/{filename}.mp4 |
https://urbafix.fr/uploads/videos/{filename}.mp4 |