Fonctionnalités Android¶
⬇️ Télécharger cette page en Markdown
Widget de Signalement Rapide¶
Description¶
Widget Android 2x1 permettant de créer un signalement en un seul tap depuis l'écran d'accueil.

Comportement¶
Installation du Widget¶
Premier lancement¶
- Dialog d'invitation automatique au premier lancement
- Titre : "Accès rapide aux signalements"
- Deux choix : "Installer le widget" ou "Plus tard"
- Préférence
widget_prompt_seensauvegardée (ne réapparaît jamais)
Depuis le Profil¶
- Section "Boutons du widget" dans l'écran Profil
- Toggles pour activer/désactiver le bouton Signaler et le bouton Alerte
- L'installation du widget se fait manuellement depuis l'écran d'accueil Android (appui long)
Compatibilité API¶
| Fonctionnalité | API 24-25 | API 26+ |
|---|---|---|
| Widget dans picker | ✅ | ✅ |
| Clic sur widget | ✅ | ✅ |
| Installation auto (requestPinAppWidget) | ❌ Instructions manuelles | ✅ Dialog système |
| Détection installation | ✅ | ✅ |
Comportement des launchers
Sur certains launchers (Samsung One UI, Xiaomi MIUI), le widget peut ne pas apparaître automatiquement même après acceptation. Un Toast informe l'utilisateur d'ajouter manuellement le widget si nécessaire.
Implémentation Technique¶
Fichiers principaux :
widget/UrbaFixWidgetProvider.kt- AppWidgetProviderwidget/WidgetInstallationManager.kt- Gestion installationui/components/WidgetInstallDialog.kt- Dialog Composeres/layout/widget_urbafix.xml- Layout RemoteViewsres/xml/widget_urbafix_info.xml- Configuration widget
Sécurité (ANSSI) :
PendingIntent.FLAG_IMMUTABLEsur tous les PendingIntents- Pas de données sensibles dans les extras Intent
- Receiver uniquement pour
APPWIDGET_UPDATEsystème
Signalement d'Incident¶
Capture Multimédia¶
- Photos : 0-3 photos par signalement
- Vidéos : 1 vidéo max, 5 secondes maximum
- Compression : Automatique avant upload
- GPS EXIF : Extraction automatique des coordonnées depuis les métadonnées des photos (v2.6.1)
- Contrôle de proximité : Vérification que toutes les photos sont dans un périmètre de 50 m (v2.6.1)
Géolocalisation¶
- Détection GPS automatique (FusedLocationClient, priorité HIGH_ACCURACY)
- Extraction GPS depuis les métadonnées EXIF des photos (v2.6.1)
- Reverse geocoding (geo.gouv.fr)
- Positionnement sur carte OSM
Filtrage Géographique (v2.0.2, mis à jour v2.1.1)¶
L'application filtre les données par code INSEE (identifiant unique par commune) plutôt que par code postal (qui peut être partagé entre plusieurs communes).
Filtrage par commune (v2.1.1)¶
Depuis la v2.1.1, l'écran d'accueil applique un double filtrage pour n'afficher que les incidents de la commune courante :
- Filtre par
mairieId(primaire) : seuls les incidents associés à la mairie de la commune détectée par GPS sont affichés - Filtre par distance (secondaire) : rayon de 10 km autour du centre de la carte
Correction v2.1.1
Avant : Les incidents des communes voisines (ex: Menton) s'affichaient à Carnolès (Roquebrune-Cap-Martin) car elles sont à moins de 10 km.
Après : Seuls les incidents de la commune courante sont affichés, grâce au filtre mairieId.
| Scénario | Avant v2.1.1 | Après v2.1.1 |
|---|---|---|
| Incidents d'une commune voisine (< 10 km) | Affichés | Filtrés (mairieId différent) |
| Incidents PENDING de la commune courante | Affichés | Affichés (mairieId correct) |
| Incidents utilisateur (sync "Mes signalements") | Affichés sur l'accueil | Exclus de l'accueil, visibles dans "Mes signalements" |
Comportement hors France (Monaco, etc.) :
- L'API
geo.gouv.frretourne un tableau vide →isOutsideFrance = true - Les incidents et alertes téléchargés sont effacés
- Accueil : Bannière jaune d'avertissement + liste incidents vide
- Signaler : Tab désactivée avec message "Signalement indisponible"
- Mes signalements : Non impacté (tous les signalements restent visibles)
Code INSEE vs Code Postal
Le code INSEE est un identifiant unique de 5 chiffres attribué à chaque commune française. Contrairement au code postal qui peut être partagé (ex: 06240 pour Beausoleil et La Turbie), le code INSEE identifie précisément une seule commune.
Résolution mairie par code INSEE (v2.4.1)¶
Lors du chargement des types d'incidents, la mairie est désormais résolue par code INSEE (prioritaire) plutôt que par code postal seul.
Correction v2.4.1
Problème : Plusieurs communes partagent le même code postal (ex: 06500 = Menton, Sainte-Agnès, Castellar, Castillon, Gorbio). La requête LIMIT 1 sans INSEE retournait la première entrée en BDD (Sainte-Agnès) même depuis le centre de Menton.
Correction : get_types.php recherche d'abord par code_insee, puis fallback sur code_postal si l'INSEE est absent ou inconnu.
| Fichier | Changement |
|---|---|
backend/public/api/get_types.php |
Recherche par code_insee en priorité |
data/remote/UrbafixApi.kt |
Param code_insee ajouté à getTypes() |
data/repository/IncidentRepository.kt |
getTypesFromApi() passe codeInsee |
ui/screens/report/ReportViewModel.kt |
loadTypes() utilise currentCodeInsee |
Types d'incidents¶
Catégories configurables côté serveur :
- Voirie (nids-de-poule, trottoirs)
- Propreté (dépôts sauvages, poubelles)
- Éclairage (lampadaires, signalisation)
- Mobilier urbain (bancs, abribus)
- Espaces verts (arbres, parcs)
GPS depuis les métadonnées EXIF (v2.6.1)¶
Lorsqu'une photo est ajoutée (caméra ou galerie), les coordonnées GPS sont extraites depuis les métadonnées EXIF et utilisées pour mettre à jour automatiquement l'adresse du signalement.
Comportement :
- La mise à jour de l'adresse depuis l'EXIF ne se déclenche que si l'utilisateur n'a pas encore de position GPS (pas de double écrasement)
- La validation France métropolitaine (41–51°N, -5–10°E) est appliquée avant toute mise à jour
- Les photos sans données EXIF GPS (ex: screenshots, images web) sont acceptées sans erreur
Contrôle de proximité 50 m¶
Si plusieurs photos possèdent des coordonnées GPS, l'application vérifie qu'elles sont toutes à moins de 50 mètres les unes des autres. Une erreur bloque l'ajout si ce n'est pas le cas.
| Élément | Détail |
|---|---|
| Algorithme | Haversine (GeoUtils.calculateDistance) |
| Seuil | 50 mètres |
| Portée | Photos avec GPS EXIF uniquement |
| Comportement erreur | Message d'erreur — photos ajoutées quand même |
Photos sans GPS
Les photos sans métadonnées GPS (screenshots, images importées) ne participent pas au contrôle de proximité et sont toujours acceptées.
Confirmation de soumission (v2.6.1)¶
Après l'envoi d'un signalement, un popup flottant apparaît pendant 1,5 seconde en bas de l'écran pour informer l'utilisateur du résultat.
| État | Couleur | Icône | Message |
|---|---|---|---|
| Envoyé au serveur | Vert #16A34A |
✓ Check | "Signalement envoyé avec succès !" |
| En attente (réseau/erreur) | Orange #F59E0B |
⏳ HourglassBottom | "Signalement en attente d'envoi" |
Implémentation :
ReportUiState.showSubmitPopup+submitPopupSuccesscontrôlent la visibilité et la couleurAnimatedVisibilityavecfadeIn/Out+slideIn/OutVerticallydepuis le basLaunchedEffect(showSubmitPopup)déclenche l'auto-dismiss après 1 500 ms (SUBMIT_RESET_DELAY_MS)resetForm()remetshowSubmitPopup = false(valeur par défaut deReportUiState)
Floutage Automatique (RGPD)¶
Fonctionnement¶
Caractéristiques¶
| Paramètre | Valeur |
|---|---|
| Technologie | ML Kit Face Detection |
| Traitement | 100% on-device |
| Algorithme | Flou gaussien (rayon 25px) |
| Marge visage | +30% autour du bounding box |
| Min face size | 10% de l'image |
Vidéos
Le floutage automatique n'est pas disponible pour les vidéos. Un dialog d'avertissement RGPD s'affiche avant toute capture vidéo.
Synchronisation Offline¶
Architecture¶
WorkManager¶
- Intervalle : 15 minutes minimum (périodique)
- Déclenchement au premier plan :
OneTimeWorkRequestlancé à chaqueonStart()deMainActivity(sync immédiate au retour dans l'app) - Reset au démarrage : les incidents bloqués en
SYNCINGsont remis enPENDINGau démarrage de l'Application - Contraintes : Réseau validé (
NET_CAPABILITY_VALIDATED) - Retry : Exponentiel automatique (erreurs réseau uniquement)
- Batch : Upload groupé des incidents en attente
Statuts de synchronisation¶
| Statut | Description | Retry |
|---|---|---|
PENDING |
En attente de sync | — |
SYNCING |
Upload en cours | — |
SYNCED |
Synchronisé avec serveur | — |
ERROR |
Échec réseau (IOException/timeout) | Automatique (WorkManager) |
ERROR_SERVER |
Erreur HTTP ≥ 400 ou success:false |
Manuel uniquement |
Différenciation des types d'erreur (v2.6.0)¶
Le SyncIncidentsWorker distingue deux catégories d'échec :
| Type d'erreur | Exemples | Statut | Comportement |
|---|---|---|---|
| Réseau | Pas de connexion, timeout, DNS | ERROR |
Result.retry() — WorkManager réessaie automatiquement |
| Serveur | HTTP 404/500, "success": false |
ERROR_SERVER |
Result.failure() — alerte utilisateur, retry manuel |
Conformité ANSSI
Les détails techniques d'erreur (code HTTP, message serveur) ne sont jamais affichés à l'utilisateur. Ils sont journalisés uniquement via CrashHandlerInitializer.captureNetworkError() pour diagnostic interne.
Indicateurs visuels (Mes signalements)¶
Chaque carte d'incident dans l'écran "Mes signalements" affiche un indicateur selon son statut de sync :
| Statut | Indicateur | Couleur |
|---|---|---|
ERROR ou ERROR_SERVER |
Bandeau + icône ⚠️ + bouton "Réessayer" | Rouge |
PENDING depuis > 5 min |
Bandeau "En attente d'envoi..." | Jaune |
SYNCING |
CircularProgressIndicator (14dp) |
Primaire |
Sauvegarde des médias avant envoi¶
Les PhotoEntity (photos + vidéos) sont maintenant insérées en base de données avant la tentative d'envoi. En cas d'échec réseau, les médias restent disponibles pour le retry.
Bouton "Réessayer"¶
Déclenché par l'utilisateur sur un incident ERROR ou ERROR_SERVER. Le retry effectue un appel API direct (pas de WorkManager différé) avec retour visuel immédiat via l'état SYNCING :
Badge onglet "Mes incidents"¶
Un badge rouge s'affiche sur l'onglet "Mes incidents" (index 2) lorsqu'au moins un incident a le statut ERROR ou ERROR_SERVER. Le badge indique le nombre d'incidents en erreur.
// Navigation bar — onglet Mes incidents
if (index == 2 && myReportsErrorCount > 0) {
BadgedBox(badge = { Badge { Text("$errorCount") } }) {
Icon(Icons.Filled.List, contentDescription = "Mes incidents")
}
}
Groupement par Proximité & Clustering¶
Algorithme¶
Incidents proches sont groupés visuellement avec un rayon dynamique basé sur le niveau de zoom.
Rayon de clustering (GeoUtils.getClusteringRadius) :
| Zoom | Rayon |
|---|---|
| ≥ 18 | 10 m |
| ≥ 17 | 30 m |
| ≥ 16 | 75 m |
| ≥ 15 | 150 m |
| ≥ 14 | 300 m |
| ≥ 13 | 600 m |
| < 13 | jusqu'à 10 km |
Le regroupement ne distingue pas les types (groupByType = false) : tous les incidents proches forment un cluster.
Formule Haversine :
fun haversineDistance(lat1, lon1, lat2, lon2): Double {
val R = 6371000.0 // Rayon Terre en mètres
val dLat = (lat2 - lat1).toRadians()
val dLon = (lon2 - lon1).toRadians()
val a = sin(dLat/2)² + cos(lat1.toRadians()) *
cos(lat2.toRadians()) * sin(dLon/2)²
return 2 * R * asin(sqrt(a))
}
Affichage¶
- Carte : Marqueur avec badge rouge (nombre d'incidents)
- Liste : Carte empilée avec effet de profondeur
- Détail : Incident principal + liste des secondaires
Toggles de clustering (Profil → Paramètres de la carte)¶
Deux options indépendantes permettent à l'utilisateur de contrôler le regroupement :
| Option | Clé SharedPrefs | Défaut | Effet |
|---|---|---|---|
| Regroupement sur la carte | map_cluster_map |
true |
false → marqueurs individuels, clic → détail direct |
| Regroupement dans la liste | map_cluster_list |
true |
false → liste plate sans cartes empilées |
Combinaisons :
| Carte | Liste | Navigation pile |
|---|---|---|
| ON | ON | Active |
| ON | OFF | Active (liste plate dans vue groupe) |
| OFF | ON | Désactivée |
| OFF | OFF | Désactivée, aucun clustering |
Navigation par groupes (v2.7.0)¶
Description¶
Cliquer sur un cluster zoome vers le groupe et filtre la carte/liste à ses incidents. Les incidents re-clusterisés au nouveau zoom forment des sous-clusters cliquables de façon récursive.
Comportement¶
Navigation back¶
- Bouton
←dans le header de liste : dépile le niveau courant + dezoom animé vers la position précédente - Swipe back / bouton physique Android : identique au bouton header
- Pile vide → retour à l'affichage normal (tous les incidents de la commune)
Données de la pile¶
data class GroupLevel(
val incidents: List<IncidentEntity>, // snapshot des incidents à ce niveau
val label: String, // ex. "Nid de poule (5)"
val prevCenter: MapPosition, // position carte avant zoom
val prevZoom: Float // zoom carte avant zoom
)
groupFilterStack: List<GroupLevel> dans HomeUiState.
Séquence complète¶
Accueil (tous) → Clic cluster rouge (5) → zoom + filtre
Vue groupe (5) → Clic sous-cluster (3) → zoom + filtre
Vue sous-groupe (3) → Clic incident individuel → IncidentDetailScreen
Fichiers concernés¶
| Fichier | Rôle |
|---|---|
ui/screens/home/HomeViewModel.kt |
GroupLevel, groupFilterStack, selectGroup(), popGroupFilter() |
ui/screens/home/HomeScreen.kt |
BackHandler, header retour, marqueurs individuels si clusterMap=false |
utils/ProfileStorageManager.kt |
clusterMap(), clusterList() |
ui/screens/profile/ProfileScreen.kt |
Toggles dans "Paramètres de la carte" |
Navigation¶
Structure¶
┌─────────────────────────────────────┐
│ TopAppBar │
│ [Logo] UrbaFix │
├─────────────────────────────────────┤
│ │
│ Content Area │
│ (selon onglet actif) │
│ │
├─────────────────────────────────────┤
│ 🏠 ➕ 📋 👤 │
│ Accueil Signaler Mes Profil │
│ incidents │
└─────────────────────────────────────┘
Onglets¶
| Index | Nom | Écran | Description |
|---|---|---|---|
| 0 | Accueil | HomeScreen | Carte + liste incidents |
| 1 | Signaler | ReportScreen | Nouveau signalement |
| 2 | Mes incidents | MyReportsScreen | Historique personnel |
| 3 | Profil | ProfileScreen | Infos + partage + widget + feedback |
Animation Carte¶
Séquence de démarrage¶
- Vue France (500ms) - Zoom 6, centre 46.5°N, 2.5°E
- Transition (1000ms) - Zoom vers position GPS, niveau ville
- Vue finale - Zoom 13, incidents affichés
// HomeViewModel companion object
private const val ANIMATION_FRANCE_DELAY_MS = 500L
private const val ANIMATION_ZOOM_DELAY_MS = 1000L
private const val RECENTER_DELAY_MS = 600L // reset isRecentering
// Zoom initial = 13f (niveau ville)
Zoom final = 13 (niveau ville)
La carte s'arrête à zoom 13 pour offrir un contexte de quartier. L'utilisateur peut zoomer manuellement jusqu'au niveau rue (zoom max 20).
Liseré Communal (v2.1.0)¶
Description¶
Affichage optionnel des limites administratives de la commune sur la carte avec zoom automatique sur l'emprise.

Activation¶
L'option est accessible dans Profil → Paramètres de la carte → Limites de la commune.
Fonctionnement¶
API geo.api.gouv.fr¶
Endpoint utilisé :
Réponse GeoJSON :
{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [[[lng, lat], [lng, lat], ...]]
},
"properties": {
"nom": "Menton",
"code": "06083",
"codesPostaux": ["06500"]
}
}
Polygones multiples
Certaines communes ont des limites complexes (îles, enclaves). L'API retourne alors un MultiPolygon qui est correctement géré par l'application.
Style du polygone¶
| Propriété | Valeur |
|---|---|
| Couleur contour | Rouge (#EF4444) |
| Épaisseur | 4px |
| Remplissage | Rouge 10% opacité (#1AEF4444) |
| Anti-aliasing | Activé |
Zoom automatique¶
Quand le contour est chargé, la carte zoome automatiquement pour afficher l'intégralité de la commune :
- Calcul de la BoundingBox (enveloppe rectangulaire)
- Animation vers la BoundingBox avec
zoomToBoundingBox() - Padding de 50px pour éviter que le contour touche les bords
Persistance¶
| Clé | Type | Défaut |
|---|---|---|
map_show_city_boundary |
Boolean | false |
La préférence est stockée dans SharedPreferences et rechargée automatiquement au démarrage de l'application.
Fichiers concernés¶
| Fichier | Rôle |
|---|---|
data/remote/GeoGouvApi.kt |
Endpoint et data classes GeoJSON |
ui/screens/home/HomeViewModel.kt |
Logique de chargement et StateFlow |
ui/screens/home/HomeScreen.kt |
Rendu du polygone osmdroid |
ui/screens/profile/ProfileScreen.kt |
Toggle utilisateur |
utils/ProfileStorageManager.kt |
Persistance de la préférence |
Gestion Hors France (v2.1.0)¶
Description¶
Lorsque l'utilisateur se trouve hors de France (Monaco, Andorre, territoires étrangers...), l'application adapte son interface pour indiquer clairement l'indisponibilité du service.
Détection¶
La détection repose sur l'API geo.api.gouv.fr qui retourne un tableau vide pour les coordonnées hors territoire français.
Comportement par écran¶
| Écran | Comportement hors France |
|---|---|
| Accueil (carte) | Affichée normalement, zoom sur position GPS |
| Accueil (bannière) | Bannière jaune : "Vous êtes actuellement hors de France" |
| Accueil (liste) | Liste vide (pas d'incidents de villes voisines) |
| Signaler | Désactivé : message "Signalement indisponible" avec icône Warning |
| Mes signalements | Non impacté (affiche tous les signalements utilisateur) |
| Profil | Non impacté |
Bannière d'avertissement (Home Screen)¶
- Position : Entre la carte et la liste des incidents
- Couleur : Fond jaune (
#FFF3E0), icône orange - Icône :
Icons.Default.Warning - Texte : "Vous êtes actuellement hors de France. Les signalements et informations ne sont pas disponibles dans cette zone."
Tab Signaler désactivée¶
Quand isOutsideFrance = true, le contenu de l'onglet Signaler est remplacé par :
- Icône : Warning rouge, 48dp
- Titre : "Signalement indisponible"
- Message : "Le service de signalement n'est disponible qu'en France métropolitaine. Déplacez-vous en France pour pouvoir signaler un incident."
- Layout : Centré verticalement et horizontalement
Mes signalements non impacté
L'écran "Mes signalements" utilise une synchronisation séparée via syncUserIncidentsFromApi() qui n'applique aucun filtre géographique. Les signalements de l'utilisateur restent visibles quelle que soit sa position.
Implémentation technique¶
HomeUiState :
Fichiers concernés :
| Fichier | Rôle |
|---|---|
ui/screens/home/HomeViewModel.kt |
Flag isOutsideFrance, filtre incidentGroups |
ui/screens/home/HomeScreen.kt |
Bannière jaune d'avertissement |
MainActivity.kt |
Tab Signaler désactivée avec message d'erreur |
Notification « Signalement résolu » (v2.5.0)¶
Description¶
Lorsqu'une mairie passe un incident au statut résolu, le citoyen déclarant reçoit automatiquement une notification push via UnifiedPush/ntfy.
Architecture¶
Mairie (backoffice/agent) → UPDATE statut = resolu
→ Lookup push_endpoints via citoyen_id
→ sendToEndpoint(ntfy URL)
→ ntfy → UrbaFixPushReceiver.onMessage()
→ Notification locale : "✅ Signalement résolu"
Chaîne de lookup SQL :
SELECT pe.endpoint
FROM push_endpoints pe
JOIN citoyens c ON c.device_id = pe.device_id
WHERE c.id = :citoyen_id
LIMIT 1
Flux complet¶
Payload de notification¶
{
"title": "✅ Signalement résolu",
"body": "Votre signalement « Nid de poule » a été résolu par la mairie.",
"incident_id": "123"
}
Points de déclenchement (Backend)¶
| Fichier | Déclencheur |
|---|---|
public/incident_detail.php |
Backoffice web — formulaire statut |
public/backend/api/update_incident.php |
API backoffice — mise à jour AJAX |
public/api/agents/incidents/update_status.php |
App agents — statut RESOLVED |
Le bloc de notification est isolé dans un try/catch : un échec push n'empêche jamais la mise à jour de la base de données.
Canal de notification Android¶
Un channel dédié urbafix_signalements ("Mes signalements") est créé séparément du channel existant urbafix_infos ("Informations officielles"), permettant à l'utilisateur de configurer indépendamment chaque type de notification.
| Channel | ID | Nom | Usage |
|---|---|---|---|
| Infos officielles | urbafix_infos |
Informations officielles | Publications mairie |
| Signalements | urbafix_signalements |
Mes signalements | Résolution d'incident |
Deep link via notification¶
Tap sur la notification → MainActivity.handleWidgetIntent() détecte l'extra incident_id → navigation directe vers l'onglet Mes signalements (index 2).
Fichiers concernés¶
| Fichier | Modification |
|---|---|
push/UrbaFixPushReceiver.kt |
Nouveau channel + parsing incident_id |
MainActivity.kt |
Deep link incident_id → tab 2 |
public/incident_detail.php |
Envoi push après résolution (backoffice) |
public/backend/api/update_incident.php |
Envoi push après résolution (API AJAX) |
public/api/agents/incidents/update_status.php |
Envoi push après RESOLVED (agents) |
Conditions requises
Le citoyen doit avoir ntfy installé et son endpoint enregistré via POST /api/register_push_endpoint.php (enregistrement automatique au premier lancement de l'app).
Feedback Utilisateur (v2.5.1)¶
Description¶
Bouton "Donner mon avis" dans l'écran Profil permettant à l'utilisateur d'accéder à la page de feedback Urbafix dans le navigateur externe.
Emplacement¶
- Section : Profil (entre "Partager l'application" et "Boutons du widget")
- Icône :
Icons.Default.RateReview - Style : Card
primaryContainer
Fonctionnement¶
URL¶
Le device_id est le fingerprint SHA-256 de l'appareil (64 caractères hex), déjà disponible dans ProfileUiState.deviceId.
Implémentation¶
Fichier : ui/screens/profile/ProfileScreen.kt
fun openFeedback() {
val deviceId = uiState.deviceId
if (deviceId.isBlank()) {
Toast.makeText(context, "Identifiant appareil non disponible.", Toast.LENGTH_SHORT).show()
return
}
val url = "https://urbafix.fr/feedback.php?device_id=$deviceId"
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
context.startActivity(intent)
}
Navigateur externe
La page s'ouvre dans le navigateur par défaut du téléphone. Aucune WebView intégrée n'est utilisée. La page feedback.php est publique et responsive.
Corrections qualité — points mineurs (v2.9.2)¶
Résolution des 9 points mineurs de l'audit de code (score 8.5/10 → 9/10).
| # | Fichier | Correction |
|---|---|---|
| 17 | utils/ColorUtils.kt (nouveau) |
parseColor() dupliquée dans 7 fichiers → parseHexColor() centralisée |
| 18 | data/repository/IncidentRepository.kt |
Log.d par photo enveloppé dans if (BuildConfig.DEBUG) |
| 19 | ui/screens/home/IncidentDetailScreen.kt |
Guard coords (0.0, 0.0) → affiche "Position non disponible" |
| 20 | ui/screens/home/IncidentDetailScreen.kt |
SimpleDateFormat → DateTimeFormatter top-level thread-safe |
| 21 | workers/SyncIncidentsWorker.kt |
URL hardcodée → RetrofitClient.BASE_URL + "api/submit_incident.php" |
| 22 | ui/screens/profile/ProfileScreen.kt |
Device ID affiché uniquement si BuildConfig.DEBUG |
| 23 | data/remote/AuthInterceptor.kt |
Logs device ID enveloppés dans if (BuildConfig.DEBUG) |
| 24 | data/remote/GeoGouvApi.kt |
create() → singleton lazy (un seul pool OkHttp partagé entre runs du worker) |
| 25 | — | HorizontalDivider reporté : Material3 1.1.2 ne l'inclut pas (disponible en 1.2.0+) |
Corrections qualité — audit de code (v2.9.1)¶
Résolution de 16 défauts identifiés lors de l'audit de code (score 7/10 → 8.5/10).
Corrections critiques¶
| # | Problème | Fichier | Correction |
|---|---|---|---|
| 1 | Incidents bloqués SYNCING après kill process |
SyncIncidentsWorker |
Reset SYNCING→PENDING au démarrage de doWork() |
| 2 | Pas de transaction sur delete+insert géo-sync | IncidentRepository |
database.withTransaction {} autour de deleteRemoteIncidents() + forEach |
| 3 | runBlocking sur thread OkHttp |
AuthInterceptor + DeviceIdManager |
Cache @Volatile + prewarm() au startup → accès synchrone |
| 4 | Fichiers disque non supprimés sur deleteIncident |
IncidentRepository |
Suppression des fichiers localPath sur Dispatchers.IO avant Room |
| 5 | Orphan cleanup limité à 200 résultats | IncidentRepository |
Suppression du mécanisme (suppressions incorrectes hors page) |
| 6 | SimpleDateFormat non thread-safe |
IncidentRepository |
Remplacement par DateTimeFormatter (instances companion, thread-safe) |
Corrections importantes¶
| # | Problème | Fichier | Correction |
|---|---|---|---|
| 7 | Race condition detectAddress ↔ fetchOrCreateMairie |
ReportViewModel |
locationJob?.cancel() + fonctions converties en suspend + séquençage |
| 8 | resetForm() après échec efface le formulaire |
ReportViewModel |
resetForm() uniquement sur succès ; sur échec, isSubmitting=false seulement |
| 9 | Double subscription _deviceId dans combine |
MyReportsViewModel |
flatMapLatest remplace combine triple |
| 10 | Toggle carte ne notifie pas HomeViewModel |
ProfileScreen |
homeViewModel?.setShowCityBoundary(checked) appelé directement |
| 11 | GsonBuilder().setLenient() accepte HTML/JSON tronqué |
RetrofitClient |
Suppression — JSON malformé lève JsonSyntaxException |
| 12 | SharedPreferences non chiffré pour données personnelles |
ProfileStorageManager |
Migration vers EncryptedSharedPreferences AES256-GCM |
| 13 | Race condition worker ↔ retryIncident |
SyncIncidentsWorker |
Skip incidents déjà SYNCING en début de boucle |
| 14 | var mapView nullable capturé dans composable |
IncidentDetailScreen |
mutableStateOf<MapView?> nommé mapViewRef |
| 15 | Pas de debounce sur la recherche | MyReportsViewModel |
_uiState.debounce(200ms) dans flatMapLatest |
| 16 | fallbackToDestructiveMigration() en production |
AppDatabase |
Conditionnel BuildConfig.DEBUG uniquement |
Fiabilité soumission & déduplication (v2.9.0)¶
Problèmes résolus¶
| Symptôme | Cause racine | Correction |
|---|---|---|
| Incident en attente même avec WiFi | NET_CAPABILITY_VALIDATED retourne false sur certains réseaux |
Suppression du check VALIDATED dans NetworkUtils |
| 10+ incidents dupliqués dans "Mes incidents" | Bouton "Envoyer" réactivé avant resetForm() (fenêtre 1,5s) |
isSubmitting reste true jusqu'à l'appel de resetForm() |
| Doublons serveur sur tap rapide "Réessayer" | Pas de garde contre les retries concurrents | _inFlightRetries: Set<Long> dans MyReportsViewModel |
| Dédup inefficace (incidents SYNCED avec serverIds différents) | La dédup groupait par serverId uniquement |
Dédup par contenu (typeId + description + coordonnées arrondies) |
| Incident disparaît de la carte après navigation | syncIncidentsFromApi écrasait deviceId avec "" |
Préservation du deviceId existant dans le chemin UPDATE |
Double syncIncidents au démarrage |
recenterMap() ne vérifiait pas isLocating |
Garde isLocating dans recenterMap() |
| Vignettes photos vides dans "Mes incidents" et détail | ByteArray passé à Coil échouait silencieusement (error=null) |
Passage en data URI string data:image/jpeg;base64,... |
| Photos 404 sur le serveur | Photos sauvées dans /uploads/ hors du document root nginx |
location ^~ /uploads/ avec alias dans nginx.conf |
Prévention des doubles soumissions¶
Avant : isSubmitting = false dès le retour API → bouton réactivé pendant le popup → re-soumission possible.
Après : isSubmitting ne revient à false que via resetForm() (qui réinitialise tout ReportUiState avec la valeur par défaut false). Pendant le popup, le bouton reste désactivé.
Déduplication locale des incidents¶
Déclenchée automatiquement à l'ouverture de l'onglet "Mes incidents" (MyReportsViewModel.init()).
Clé de regroupement : Triple(typeId, description, "${(lat * 1000).toLong()},${(lon * 1000).toLong()}") — précision ~100 m.
Filtre carte Home (incidents confirmés uniquement)¶
// Avant (v2.8.0)
if (incident.deviceId.isNotEmpty()) return@filter false // ← trop agressif
// Après (v2.9.0)
if (incident.syncStatus != SyncStatus.SYNCED) return@filter false // ← sémantique correcte
| Statut | Affiché sur carte Home | Visible dans "Mes incidents" |
|---|---|---|
SYNCED (n'importe quel deviceId) |
✅ | ✅ |
PENDING |
❌ | ✅ |
ERROR / ERROR_SERVER |
❌ | ✅ avec bandeau rouge |
SYNCING |
❌ | ✅ avec spinner |
Suppression d'un incident en erreur¶
Nouveau bouton "Supprimer" dans les bandeaux de statut de l'écran "Mes incidents" :
| Bandeau | Boutons |
|---|---|
ERROR / ERROR_SERVER (rouge) |
Réessayer + Supprimer |
PENDING > 5 min (jaune) |
Supprimer |
// MyReportsViewModel
fun deleteIncident(incidentId: Long) {
viewModelScope.launch {
val incident = repository.getIncidentById(incidentId) ?: return@launch
repository.deleteIncident(incident)
}
}
Affichage photos (data URI)¶
Avant : android.util.Base64.decode(raw, Base64.DEFAULT) → ByteArray → Coil échouait silencieusement.
Après : string data URI passée directement à Coil :
// IncidentDetailScreen.kt + MyReportsScreen.kt
!photo.photoData.isNullOrEmpty() -> {
if (photo.photoData.startsWith("data:")) photo.photoData
else "data:image/jpeg;base64,${photo.photoData}"
}
Coil 2.5.0 intègre nativement DataUriFetcher pour les strings data:image/....
Infrastructure nginx — uploads hors document root¶
Les photos sont stockées dans /var/www/html/uploads/incidents/ (hors du document root /var/www/html/public/). Sans configuration spécifique, les URLs /uploads/incidents/... retournent HTTP 404.
Correction dans nginx.conf :
# ^~ : prioritaire sur les regex ~* .(jpg|...) qui interceptaient avant
location ^~ /uploads/ {
alias /var/www/html/uploads/;
expires 1y;
add_header Cache-Control "public";
}
Le ^~ empêche nginx de tester les locations regex (comme ~* \.(jpg|jpeg|...)$) pour les requêtes commençant par /uploads/, permettant de servir les fichiers depuis le bon répertoire.
Déduplication côté serveur — submit_incident.php¶
Avant d'insérer un incident, le serveur vérifie si le même citoyen a déjà soumis un incident identique dans les 6 dernières heures :
SELECT id FROM incidents
WHERE citoyen_id = ? AND type_id = ? AND description = ?
AND ABS(latitude - ?) < 0.0005
AND ABS(longitude - ?) < 0.0005
AND created_at > DATE_SUB(NOW(), INTERVAL 6 HOUR)
LIMIT 1
Si un doublon est trouvé :
1. Les photos de la requête sont sauvegardées sur l'incident existant si celui-ci n'en a pas encore
2. L'incident_id existant est retourné dans la réponse ("duplicate": true)
3. L'app met à jour le statut local en SYNCED avec le bon serverId
Cela couvre le cas de la perte de réponse réseau (timeout) : la requête ré-envoyée n'insère pas de doublon et les photos finissent par être uploadées.
Résilience Offline — Types par défaut & Feedback réseau (v2.8.0)¶
Problèmes résolus¶
| Avant | Après |
|---|---|
| Formulaire inutilisable si serveur KO (aucun type affiché) | 6 types génériques disponibles immédiatement |
| GPS bloquant : types chargés uniquement après détection GPS | Types affichés dès l'ouverture, GPS en arrière-plan |
| Popup orange générique "en attente d'envoi" | Bannière persistante distinguant OFFLINE vs SERVER_ERROR |
| "Aucun signalement" trompeur pendant la recherche GPS | Indicateur GPS animé sur l'écran Accueil |
Types par défaut (Room cache + assets JSON)¶
Architecture à deux niveaux¶
loadDefaultTypes() [init ViewModel]
│
├─ Room[codeInsee] non vide ──→ affiche cache commune (isUsingDefaultTypes = false)
│
└─ Room vide ─────────────────→ assets/default_types.json (isUsingDefaultTypes = true)
GPS détecté → loadTypes(codeInsee)
│
├─ Étape 1 : getTypesWithFallback(codeInsee) → affichage immédiat
│
└─ Étape 2 : API get_types.php
├─ Succès → cacheTypes() → Room mis à jour → isUsingDefaultTypes = false
└─ Échec → garde affichage actuel, positionne networkStatus
Fichier assets/default_types.json¶
6 types génériques embarqués dans l'APK (IDs négatifs pour éviter les collisions serveur) :
{
"version": 1,
"types": [
{ "id": -1, "nom": "Nid-de-poule", "couleur": "#EF4444" },
{ "id": -2, "nom": "Dépôt sauvage", "couleur": "#F59E0B" },
{ "id": -3, "nom": "Éclairage défaillant", "couleur": "#EAB308" },
{ "id": -4, "nom": "Signalisation dégradée", "couleur": "#3B82F6" },
{ "id": -5, "nom": "Trottoir endommagé", "couleur": "#8B5CF6" },
{ "id": -6, "nom": "Autre", "couleur": "#6B7280" }
]
}
Mise à jour admin
Quand un admin ajoute un type via le backoffice, il est capturé au prochain appel API réussi → Room mis à jour automatiquement. Aucune mise à jour APK requise.
Table Room incident_types¶
@Entity(tableName = "incident_types", primaryKeys = ["id", "codeInsee"])
data class IncidentTypeEntity(
val id: Long,
val codeInsee: String, // Code INSEE de la commune
val nom: String,
val couleur: String,
val cachedAt: Long
)
Sécurité
La validation mairieId == null → submit bloqué garantit que les types avec IDs négatifs n'atteignent jamais le serveur. Si mairieId est défini, Room contient déjà les vrais types de la commune.
Bannière réseau contextuelle¶
La bannière apparaît automatiquement en haut du formulaire de signalement selon l'état réseau :
networkStatus |
Couleur | Icône | Message |
|---|---|---|---|
OFFLINE |
Amber #FFF3CD |
📵 | Hors connexion — Votre signalement sera sauvegardé et envoyé automatiquement à la reconnexion. |
SERVER_ERROR |
Rouge #FEE2E2 |
⚠️ | Serveur indisponible — Votre signalement sera sauvegardé et soumis automatiquement dès que le serveur répond. |
ONLINE |
— | — | Bannière masquée |
Détection automatique¶
fun detectNetworkStatus(e: Exception): NetworkStatus =
if (!NetworkUtils.isNetworkAvailable(context)) NetworkStatus.OFFLINE
else NetworkStatus.SERVER_ERROR
Déclenchée sur :
initViewModel →OFFLINEsi réseau absent- Échec
loadTypes()→OFFLINEouSERVER_ERROR - Échec
sendIncidentToApi()→OFFLINEouSERVER_ERROR - Succès API → reset à
ONLINE
Indicateur GPS animé¶
Écran Signalement¶
Quand isLoadingLocation = true, le champ position affiche un point amber pulsant (animation InfiniteTransition) + texte "Localisation en cours…" au lieu du champ vide.
Sous le dropdown des types, quand isUsingDefaultTypes = true :
Types génériques — mis à jour après détection GPS
Le hint disparaît (AnimatedVisibility) dès que les types de la commune sont chargés depuis l'API.
Écran Accueil¶
Quand isLocating = true (pendant centerMapOnCurrentLocation()), l'empty state de CombinedList affiche :
Remplace l'ancien "Aucun signalement" trompeur pendant les ~1,5 secondes d'animation GPS initiale.
Fichiers modifiés¶
| Fichier | Modifications |
|---|---|
assets/default_types.json |
Nouveau — 6 types génériques |
data/local/entities/IncidentTypeEntity.kt |
Nouveau — entité Room cache |
data/local/dao/IncidentTypeDao.kt |
Nouveau — CRUD avec @Transaction |
data/local/AppDatabase.kt |
Version 8 → 9, ajout incident_types |
data/repository/IncidentRepository.kt |
getTypesWithFallback(), cacheTypes() |
ui/screens/report/ReportViewModel.kt |
NetworkStatus, isUsingDefaultTypes, loadDefaultTypes() |
ui/screens/report/ReportScreen.kt |
Bannière, GPS pulsant, hint types |
ui/screens/home/HomeViewModel.kt |
Flag isLocating |
ui/screens/home/HomeScreen.kt |
Empty state GPS dans CombinedList |
Logs de débogage (v3.0.0)¶
Description¶
Système de journalisation persistant écrit dans un fichier .log interne accessible sans ligne de commande. Conçu pour le diagnostic terrain : l'utilisateur peut partager le fichier directement depuis l'écran Profil.
Architecture¶
DebugLogger (singleton)
│
├─ write() @Synchronized ──→ filesDir/urbafix-debug.log
│ │ Rotation à 2 Mo → .bak
│ └─ Log.println() ──→ Logcat (miroir)
│
├─ DebugNetworkInterceptor ──→ chaque appel HTTP (méthode, URL, code, durée)
├─ CrashHandler ──→ chaque crash non capturé
├─ NetworkUtils ──→ transport réseau (WIFI / CELLULAR / ETHERNET)
├─ MainActivity ──→ navigation entre onglets
├─ MonQuartierApplication ──→ démarrage app (modèle, version Android)
├─ ReportViewModel ──→ soumission (typeId, photos, lat/lon, résultat)
├─ SyncIncidentsWorker ──→ cycle WorkManager (N incidents, ok/échecs)
└─ HomeViewModel ──→ GPS obtenu, commune détectée, sync résultat
Format des entrées¶
2026-06-18T20:37:00.261 I [GPS] Position obtenue — lat=43,7857, lon=7,4939
2026-06-18T20:37:01.978 I [Sync] Commune détectée — Menton (INSEE=06083, CP=06500)
2026-06-12T08:15:15.025 E [Submit] Échec envoi immédiat (Exception): Erreur HTTP 500
2026-06-12T08:16:31.064 I [Sync] Incident #7 envoyé → serverId=90
I = Info · D = Debug · W = Warning · E = Error
Rotation des fichiers¶
| Paramètre | Valeur |
|---|---|
| Fichier actif | filesDir/urbafix-debug.log |
| Backup | filesDir/urbafix-debug.log.bak |
| Taille max | 2 Mo |
| Comportement | À 2 Mo, .log → .bak, nouveau .log |
Le backup est écrasé à chaque rotation (2 Mo max conservés).
Export depuis l'application¶
Dans Profil → Logs de débogage (visible uniquement en build DEBUG) :
- Taille courante du fichier affichée en Ko
- Partager : Android share sheet (email, WhatsApp, AirDrop…)
- Effacer : supprime
.loget.bak
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
DebugLogger.getLogFile(context)
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
Build DEBUG uniquement
La carte "Logs de débogage" n'apparaît pas dans les builds release. Les données sensibles ne sont jamais loggées (coordonnées GPS tronquées à 2 décimales, pas de photos, pas d'email, pas de fingerprint complet).
Confidentialité (RGPD / ANSSI)¶
| Donnée | Comportement |
|---|---|
| Coordonnées GPS | Tronquées à 2 décimales ("%.2f") |
| Photos / vidéos | Jamais loggées |
| Device ID / fingerprint | Jamais loggé |
| Email citoyen | Jamais loggé |
| Code HTTP, durée, URL | Loggés (diagnostic réseau) |
Fichiers concernés¶
| Fichier | Rôle |
|---|---|
utils/DebugLogger.kt |
Singleton — écriture, rotation, getLogFile, clearLogs |
data/remote/DebugNetworkInterceptor.kt |
Intercepteur OkHttp — log chaque requête HTTP |
data/remote/RetrofitClient.kt |
Ajout de DebugNetworkInterceptor à OkHttpClient |
MonQuartierApplication.kt |
DebugLogger.init(this) en premier dans onCreate() |
MainActivity.kt |
Log navigation onglets |
utils/NetworkUtils.kt |
Log transport réseau sur chaque vérification |
crash/CrashHandler.kt |
Log crash avec thread et stacktrace |
ui/screens/report/ReportViewModel.kt |
Log submit start/success/failure/no-network |
workers/SyncIncidentsWorker.kt |
Log sync start/per-incident/summary |
ui/screens/home/HomeViewModel.kt |
Log GPS, commune, sync résultat |
ui/screens/profile/ProfileScreen.kt |
Carte export (DEBUG builds) |
res/xml/file_paths.xml |
<files-path name="logs" path="." /> pour FileProvider |
Retry immédiat après échec serveur (v3.0.0)¶
Problème¶
En cas d'erreur serveur (HTTP 500, timeout) lors de la soumission d'un incident, l'incident restait PENDING jusqu'au prochain cycle WorkManager périodique (toutes les 15 minutes). Si l'échec se produisait en début de cycle, l'utilisateur attendait jusqu'à 15 minutes avant que l'incident soit renvoyé automatiquement.
Solution¶
Quand sendIncidentToApi() retourne un échec non-réseau (ex: HTTP 500), un OneTimeWorkRequest est déclenché immédiatement avec une contrainte réseau CONNECTED :
WorkManager.getInstance(context).enqueueUniqueWork(
"SyncIncidentsNow",
ExistingWorkPolicy.KEEP,
OneTimeWorkRequestBuilder<SyncIncidentsWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.build()
)
ExistingWorkPolicy.KEEP : si plusieurs incidents échouent en rafale, une seule tâche est enregistrée (pas de doublons).
Comportement avant / après¶
| Scénario | Avant | Après |
|---|---|---|
| Submit réussi immédiatement | SYNCED instantané | Inchangé |
| Pas de réseau | PENDING, retry au prochain cycle (≤ 15 min) | Inchangé (contrainte CONNECTED) |
| Erreur serveur (HTTP 5xx) | PENDING, attente ≤ 15 min | PENDING → retry dans les secondes qui suivent |
| Erreur réseau (IOException) | PENDING, retry WorkManager | Inchangé |
Mise à jour de la liste
Quand SyncIncidentsWorker réussit, incidentDao.update() met à jour Room → la Room Flow dans MyReportsViewModel émet → Compose recompose → la liste se met à jour automatiquement (pas besoin de redémarrer l'app).
Fichier modifié¶
| Fichier | Modification |
|---|---|
ui/screens/report/ReportViewModel.kt |
enqueueUniqueWork("SyncIncidentsNow", KEEP, ...) après échec sendIncidentToApi |
État du bouton après soumission (v3.0.1)¶
Description¶
Après l'envoi d'un signalement, le bouton change d'apparence pour refléter le résultat et reste non-cliquable jusqu'à la réinitialisation du formulaire, éliminant tout risque de double soumission.
Machine d'états¶
Apparences du bouton¶
| État | Couleur fond | Icône | Texte |
|---|---|---|---|
| Idle | Primaire (thème) | — | ✈️ Envoyer le signalement |
| Soumission en cours | Primaire | CircularProgressIndicator | Envoi en cours… |
| Envoyé avec succès | Vert #16A34A |
✓ Check | Signalement envoyé ! |
| Sauvegardé (offline/erreur) | Orange #F59E0B |
⏳ HourglassBottom | Sauvegardé — envoi automatique |
Implémentation¶
Champ ajouté à ReportUiState :
Logique dans submitReport() :
- Succès :
isSubmitting=false, isSubmitted=true, submitPopupSuccess=true→ reste jusqu'àresetForm() - Échec :
isSubmitting=false, isSubmitted=true→delay(3000L)→isSubmitted=false
Bouton dans ReportScreen.kt :
enabled = !uiState.isSubmitting && !uiState.isSubmitted
colors = when {
uiState.isSubmitted && uiState.submitPopupSuccess ->
ButtonDefaults.buttonColors(
disabledContainerColor = Color(0xFF16A34A),
disabledContentColor = Color.White
)
uiState.isSubmitted ->
ButtonDefaults.buttonColors(
disabledContainerColor = Color(0xFFF59E0B),
disabledContentColor = Color.White
)
else -> ButtonDefaults.buttonColors()
}
Override couleur Material3
disabledContainerColor est nécessaire pour afficher la couleur de statut quand enabled = false — Material3 applique sinon un gris par défaut.
Bascule mode d'envoi email (DEBUG, v3.0.1)¶
Description¶
En build DEBUG, un toggle dans l'écran Profil permet de choisir entre :
- Mode TEST : tous les emails de notification à la mairie sont redirigés vers l'adresse de test
- Mode PROD : les emails sont envoyés à l'adresse réelle de la mairie
Emplacement¶
Profil → "Mode d'envoi des emails" — affiché uniquement en BuildConfig.DEBUG, avant la carte "Logs de débogage".
Flux complet¶
Architecture technique¶
Backend :
- Table
app_settings(clé-valeur) avec entrée initialeemail_test_mode = '1' - Endpoint
GET/POST /api/admin/email_test_mode.php(protégé parrequireAuth()) EmailService.phplitemail_test_modeà chaque envoi — pas de valeur hardcodée
Android :
- DTO
EmailTestModeResponse—data/remote/dto/EmailTestModeDto.kt MonQuartierApi:getEmailTestMode()+setEmailTestMode(@Body Map<String, Boolean>)ProfileViewModel.loadEmailTestMode()appelé auinit,setEmailTestMode(Boolean)sur changement toggleProfileUiState.emailTestMode+isLoadingEmailMode
UI de la carte¶
| Mode | Fond de carte | Couleur switch | Description |
|---|---|---|---|
| TEST actif | Jaune #FFF3CD |
Orange | Emails → adresse de test |
| PROD actif | Vert #D1FAE5 |
Vert | Emails → mairie réelle |
Un CircularProgressIndicator remplace le switch pendant la requête réseau (isLoadingEmailMode = true).
Fichiers concernés¶
| Fichier | Rôle |
|---|---|
backend/src/EmailService.php |
Lecture dynamique email_test_mode depuis DB |
backend/public/api/admin/email_test_mode.php |
Endpoint GET/POST (nouveau) |
data/remote/dto/EmailTestModeDto.kt |
DTO de réponse (nouveau) |
data/remote/MonQuartierApi.kt |
getEmailTestMode() + setEmailTestMode() |
ui/screens/profile/ProfileViewModel.kt |
loadEmailTestMode(), setEmailTestMode(), état |
ui/screens/profile/ProfileScreen.kt |
EmailModeCard composable (DEBUG uniquement) |
DEBUG uniquement
La carte EmailModeCard n'est pas compilée dans les builds release. En production, la valeur est modifiable directement en base via la table app_settings.