Aller au contenu

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.

Widget UrbaFix

Comportement

Séquence : le tap sur le widget ouvre directement l'écran de signalement

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_seen sauvegardé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 - AppWidgetProvider
  • widget/WidgetInstallationManager.kt - Gestion installation
  • ui/components/WidgetInstallDialog.kt - Dialog Compose
  • res/layout/widget_urbafix.xml - Layout RemoteViews
  • res/xml/widget_urbafix_info.xml - Configuration widget

Sécurité (ANSSI) :

  • PendingIntent.FLAG_IMMUTABLE sur tous les PendingIntents
  • Pas de données sensibles dans les extras Intent
  • Receiver uniquement pour APPWIDGET_UPDATE systè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).

Flowchart : filtrage géographique par code INSEE

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 :

  1. Filtre par mairieId (primaire) : seuls les incidents associés à la mairie de la commune détectée par GPS sont affichés
  2. Filtre par distance (secondaire) : rayon de 10 km autour du centre de la carte

Flowchart : double filtrage des incidents de l'accueil par mairieId puis par distance

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.fr retourne 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)

Flowchart : effets du drapeau isOutsideFrance sur les écrans Accueil, Signaler et Mes signalements

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.

Flowchart : résolution de la mairie par code INSEE avec repli sur le code postal

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.

Flowchart : extraction et validation du GPS depuis les métadonnées EXIF d'une photo

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.

Flowchart : contrôle de proximité de 50 m entre les photos géolocalisées d'un signalement

É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.

Flowchart : popup de confirmation après soumission d'un signalement

É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 + submitPopupSuccess contrôlent la visibilité et la couleur
  • AnimatedVisibility avec fadeIn/Out + slideIn/OutVertically depuis le bas
  • LaunchedEffect(showSubmitPopup) déclenche l'auto-dismiss après 1 500 ms (SUBMIT_RESET_DELAY_MS)
  • resetForm() remet showSubmitPopup = false (valeur par défaut de ReportUiState)

Floutage Automatique (RGPD)

Fonctionnement

Flowchart : floutage automatique des visages avant compression et upload

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

Machine à états : cycle de vie PENDING → SYNCING → SYNCED avec gestion des erreurs

WorkManager

  • Intervalle : 15 minutes minimum (périodique)
  • Déclenchement au premier plan : OneTimeWorkRequest lancé à chaque onStart() de MainActivity (sync immédiate au retour dans l'app)
  • Reset au démarrage : les incidents bloqués en SYNCING sont remis en PENDING au 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 :

Flowchart : différenciation des échecs réseau et serveur lors de la synchronisation

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 :

Flowchart : retry manuel déclenché par le bouton Réessayer

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

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

Flowchart : navigation récursive par groupes de clustering

  • 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"

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

  1. Vue France (500ms) - Zoom 6, centre 46.5°N, 2.5°E
  2. Transition (1000ms) - Zoom vers position GPS, niveau ville
  3. 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.

Liseré communal

Activation

L'option est accessible dans Profil → Paramètres de la carte → Limites de la commune.

Fonctionnement

Séquence : activation du liseré des limites communales sur la carte

API geo.api.gouv.fr

Endpoint utilisé :

GET https://geo.api.gouv.fr/communes/{codeInsee}?format=geojson&geometry=contour

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 :

  1. Calcul de la BoundingBox (enveloppe rectangulaire)
  2. Animation vers la BoundingBox avec zoomToBoundingBox()
  3. 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

Flowchart : détection d'une position hors du territoire français

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 :

data class HomeUiState(
    val isOutsideFrance: Boolean = false,
    // ... autres champs
)

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

Séquence : notification push envoyée au citoyen quand son signalement est résolu

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

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

Séquence : accès à la page de feedback depuis le profil

URL

https://urbafix.fr/feedback.php?device_id={DEVICE_ID}

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

Machine à états : prévention des doubles soumissions du bouton d'envoi

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()).

Flowchart : déduplication locale des incidents à l'ouverture de Mes incidents

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 :

  • init ViewModel → OFFLINE si réseau absent
  • Échec loadTypes() → OFFLINE ou SERVER_ERROR
  • Échec sendIncidentToApi() → OFFLINE ou SERVER_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 :

    [●]  ← point amber pulsant
Recherche de votre position…
Les signalements apparaîtront ensuite

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 .log et .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

Machine à états : apparence du bouton après soumission d'un signalement

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 :

val isSubmitted: Boolean = false

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

Séquence : bascule du mode d'envoi des emails entre test et production

Architecture technique

Backend :

  • Table app_settings (clé-valeur) avec entrée initiale email_test_mode = '1'
  • Endpoint GET/POST /api/admin/email_test_mode.php (protégé par requireAuth())
  • EmailService.php lit email_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é au init, setEmailTestMode(Boolean) sur changement toggle
  • ProfileUiState.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.