Aller au contenu

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.

Cycle de lecture/écriture offline-first : l'utilisateur écrit via le ViewModel vers Room (PENDING) puis l'API en async, Room notifie le ViewModel par Flow qui alimente l'écran Compose

Statuts de synchronisation

Machine à états des statuts de synchronisation : PENDING vers SYNCING puis SYNCED en cas de succès, ou ERROR / ERROR_SERVER avec retour possible vers SYNCING ou PENDING


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

Séquence de soumission d'un incident : succès direct vers SYNCED, ou maintien en PENDING et retry par SyncWorker/WorkManager avec déduplication serveur possible

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