Documentation Technique UrbaFix

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Instructions de concision

Sécurité

Security Implementation (ANSSI Compliant)

Database Encryption (SQLCipher)

File: data/local/AppDatabase.kt:35-41

// Load native SQLCipher library (required for sqlcipher-android)
System.loadLibrary("sqlcipher")

val passphrase = FingerprintManager.generateFingerprint(context.applicationContext).toByteArray(StandardCharsets.UTF_8)
val factory = SupportOpenHelperFactory(passphrase)

Certificate Pinning

File: data/remote/RetrofitClient.kt:71-73

val certificatePinner = CertificatePinner.Builder()
    .add("urbafix.fr", "sha256/PU4bTrzvP9A7Ft6KDCL+E90q/7+CT/H9oB0iwTy6iS8=")
    .build()

Network Security Configuration

File: res/xml/network_security_config.xml

<base-config cleartextTrafficPermitted="false">
<domain-config cleartextTrafficPermitted="false">
    <domain includeSubdomains="true">urbafix.fr</domain>
    <pin-set expiration="2026-12-31">
        <pin digest="SHA-256">PU4bTrzvP9A7Ft6KDCL+E90q/7+CT/H9oB0iwTy6iS8=</pin>

Conditional Logging

File: data/remote/RetrofitClient.kt:59-63

level = if (BuildConfig.DEBUG) {
    HttpLoggingInterceptor.Level.BODY
} else {
    HttpLoggingInterceptor.Level.NONE
}

Security Dependencies

// SQLCipher with 16KB page alignment support (Android 15+)
implementation("net.zetetic:sqlcipher-android:4.12.0@aar")
implementation("androidx.sqlite:sqlite:2.4.0")

// Android Gradle Plugin 8.7.3 (AGP 8.5.1+ required for automatic 16KB alignment)
id("com.android.application") version "8.7.3"

Certificate Pin Renewal

When urbafix.fr certificate is renewed:

# 1. Get new pin
echo | openssl s_client -connect urbafix.fr:443 -servername urbafix.fr | openssl x509 -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | base64

# 2. Update both files
# - RetrofitClient.kt line 72
# - network_security_config.xml line 17

# 3. Update expiration date in network_security_config.xml line 16

Known Security Limitations

See SECURITE.md for complete security audit and remaining vulnerabilities: - Authentication by fingerprint only (no JWT/OAuth2) - Priority 3 - Input validation and sanitization needed - Priority 4 - Backup encryption disabled (android:allowBackup=“true”) - Priority 5

Resolved (Dec 2025): - ✅ Server-side photos encryption (AES-256-GCM) - ✅ Rate limiting on API endpoints (APCu 100 req/min) - ✅ CORS whitelist enforcement

Project Overview

UrbaFix is a native Android application (Kotlin) for citizen incident reporting to local municipalities. The app features offline-first architecture with deferred synchronization, designed to work in areas without network coverage.

Core Capability: Users can report incidents (potholes, trash, broken lighting, etc.) without internet connection. Reports are stored locally in Room database and automatically synchronized when network becomes available.

Backend and Web Dashboard

Location: /home/franck/AndroidStudioProjects/UrbaFix/www/

This directory contains: - API endpoints (PHP files used by the Android app) - Municipality dashboard (web interface for municipalities)

IMPORTANT - SSHFS Mount: - This is an SSHFS mount to the production server - Any modifications are LIVE immediately - MANDATORY: Before ANY modification, create a backup in a dedicated rollback directory

Backup Protocol:

# Before modifying any file in www/
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/home/franck/AndroidStudioProjects/UrbaFix/backups/$TIMESTAMP"
mkdir -p "$BACKUP_DIR"
cp -r /home/franck/AndroidStudioProjects/UrbaFix/www/* "$BACKUP_DIR/"
echo "Backup created: $BACKUP_DIR"

# Make your changes...

# To rollback if needed:
# cp -r "$BACKUP_DIR"/* /home/franck/AndroidStudioProjects/UrbaFix/www/

Critical Files: - API endpoints: www/public/api/*.php - Dashboard: www/public/*.php (index.php, incidents.php, etc.) - Database config: Check for any DB connection files

Dashboard Incident Grouping: The web dashboard (www/public/index.php and www/public/incidents.php) implements the same incident grouping as the Android app: - Algorithm: Haversine distance calculation + same type filtering (10m threshold) - Visual: Stacked card effect with red badge counter - Files: Both use calculateDistance() and groupIncidents() functions - Consistency: Mirrors Android app behavior (GeoUtils.kt)

Safety Rules: 1. ALWAYS backup before modifications 2. Test changes on development server first if possible 3. Document all changes in backup folder (create CHANGES.txt) 4. Never delete backups (they are your rollback insurance)

Build and Development Commands

Building

# Clean and build debug APK
./gradlew clean assembleDebug

# Build release APK
./gradlew assembleRelease

# Install debug APK on connected device
./gradlew installDebug

# Run the app
adb shell am start -n com.example.monquartierkotlin/.MainActivity

Testing

# Run unit tests
./gradlew test

# Run instrumented tests
./gradlew connectedAndroidTest

# Run tests for a specific class
./gradlew test --tests "IncidentRepositoryTest"

Database Debugging

# Access app database via ADB
adb shell
run-as com.example.monquartierkotlin
cd databases
sqlite3 urbafix_database

# Useful queries
SELECT * FROM incidents WHERE syncStatus = 'PENDING';
SELECT COUNT(*) FROM incidents;

Logging

# Monitor app logs
adb logcat | grep -E "(IncidentRepository|HomeViewModel|ProfileViewModel|SyncIncidentsWorker)"

# Monitor HTTP requests
adb logcat | grep -E "OkHttp"

Architecture

MVVM Pattern

The app follows Model-View-ViewModel architecture: - Models: Room entities in data/local/entities/ - ViewModels: In ui/screens/*/ViewModel.kt files - Views: Jetpack Compose screens in ui/screens/*/Screen.kt files

Offline-First Strategy

  1. Read Path: All data displayed comes from Room database (single source of truth)
  2. Write Path: New incidents saved locally with syncStatus = PENDING
  3. Sync Path: WorkManager periodically uploads pending incidents when network available
  4. Update Path: Successful uploads update syncStatus = SYNCED and add serverId

Data Layer Organization

data/
├── local/                      # Room database layer
│   ├── entities/              # Database tables
│   │   ├── IncidentEntity     # Main incident table
│   │   ├── PhotoEntity        # Photos linked to incidents
│   │   └── MairieEntity       # Municipality info cache
│   ├── dao/                   # Data Access Objects
│   └── AppDatabase.kt         # Room configuration
├── remote/                    # Network layer
│   ├── api/UrbafixApi.kt  # Retrofit interface
│   ├── dto/                   # API request/response DTOs
│   └── RetrofitClient.kt      # HTTP client config
└── repository/
    └── IncidentRepository.kt  # Mediates between local and remote

Synchronization Architecture

Key Implementation Details

API Configuration

Base URL is configured in data/remote/RetrofitClient.kt:

private const val BASE_URL = "https://urbafix.fr/"

For local development, change to: - Emulator: http://10.0.2.2:8080/ - Physical device: http://192.168.1.X:8080/

Dynamic Postal Code (v1.4.0)

Postal code is automatically detected based on GPS position: - HomeViewModel.syncIncidents() uses repository.getCommuneData() to get postal code from GPS coordinates - Uses geo.gouv.fr API for reverse geocoding (latitude, longitude → commune name, INSEE code, postal code) - Fallback to “06500” (Menton) if geolocation fails - data/repository/IncidentRepository.kt:244-276 - ui/screens/home/HomeViewModel.kt:116-144

Note: ProfileViewModel still uses hardcoded “06500” for initial registration (to be updated)

Device Identification

Each device gets a unique fingerprint via DeviceIdManager.getDeviceId() which uses FingerprintManager. This fingerprint is based on: - Android ID (Settings.Secure.ANDROID_ID) - Hardware information (Build.MANUFACTURER, MODEL, HARDWARE, etc.) - Device sensors list (name, vendor, version)

The fingerprint is: - Deterministic: Same device = same fingerprint (even after reinstall) - Stable: Persists across app reinstalls and Android updates - Anonymous: SHA-256 hash (64 hex characters) - Compliant: Uses only authorized Android APIs, no forbidden identifiers

Key characteristics: - Links users to their incidents (anonymous by default) - Persists across app reinstalls (tied to device hardware) - Used for profile management - Does NOT change when Android version updates

See FINGERPRINT.md for detailed documentation.

Photo and Video Handling (v1.5.0)

Android Local Storage

Photos and videos are stored in the photos_incident table (PhotoEntity): - Media Types: mediaType field (PHOTO or VIDEO) - Local: File path in PhotoEntity.localPath or Base64 in photoData - Server: URL in PhotoEntity.serverUrl (null if not yet uploaded) - Photo Compression: Handled by utils/ImageCompressor.kt before storage - Video Compression: Handled by utils/VideoCompressor.kt (max 5 seconds, max 5 MB) - Automatic truncation if video > 5 seconds - Validation: duration, dimensions, file size - Format: MP4 (MediaMuxer conversion) - Video Duration: Stored in PhotoEntity.durationMs field - Display: Coil AsyncImage with URL normalization (prefixes relative URLs with https://urbafix.fr) - Grouping: IncidentDetailViewModel combines photos/videos from all incidents in a group using Flow

API Upload (JSON Format)

File: data/repository/IncidentRepository.kt:406-502, data/remote/api/UrbafixApi.kt:67-69

Photos and videos are uploaded via JSON to submit_incident.php:

// DTO Structure
data class VideoData(
    val video: String,        // "data:video/mp4;base64,..."
    val latitude: Double?,    // GPS coordinates
    val longitude: Double?
)

data class SubmitIncidentRequest(
    val mairieId: Long,
    val typeId: Long,
    val adresse: String,
    val latitude: Double,
    val longitude: Double,
    val description: String,
    val deviceId: String,
    val photos: List<String>?,      // Base64 with prefix
    val videos: List<VideoData>?    // Objects with GPS
)

Upload Flow: 1. sendIncidentToApi() receives List<PhotoEntity> (photos + videos) 2. Filters by mediaType (PHOTO vs VIDEO) 3. Photos → photos array with Base64 prefix validation 4. Videos → videos array as VideoData objects with GPS 5. Sends via api.submitIncidentJson(request) (POST with @Body)

API Endpoint: POST /api/submit_incident.php (Content-Type: application/json)

Server Storage (Backend)

Photos Table (photos_incident): - Storage: /uploads/incidents/ - Format: JPEG (blurred automatically) - Columns: id, incident_id, type, filename, filepath, description, latitude, longitude, encrypted

Videos Table (videos_incident) - NEW in v1.5.0: - Storage: /uploads/videos/ - Format: MP4 (H.264 + AAC) - Columns: id, incident_id, type, filename, filepath, description - Additional: duration (seconds), filesize (bytes), mime_type, latitude, longitude, encrypted - Foreign Key: incident_id REFERENCES incidents(id) ON DELETE CASCADE - Indexes: incident_id, type, encrypted, GPS (latitude, longitude)

PHP Processing (submit_incident.php:176-241):

// Extract videos array from JSON
foreach ($data['videos'] as $index => $videoData) {
    $videoBase64 = $videoData['video'];  // "data:video/mp4;base64,..."
    $videoLat = $videoData['latitude'];
    $videoLng = $videoData['longitude'];

    // Decode and save to /uploads/videos/
    $filename = $incidentId . '_video_' . time() . '_' . $index . '.mp4';

    // Insert into videos_incident table
    INSERT INTO videos_incident (
        incident_id, type, filepath, filename,
        latitude, longitude, filesize, mime_type
    ) VALUES (...)
}

Synchronization

Automatic Face Blurring (GDPR Compliance)

File: utils/FaceBlurrer.kt, utils/ImageCompressor.kt

All photos are automatically anonymized before upload to ensure GDPR compliance:

Processing Pipeline (ImageCompressor.kt:45-115): 1. Load image from URI 2. Correct EXIF orientation 3. Resize to max 1024px 4. Blur faces (mandatory, on-device) 5. JPEG compression (85%) 6. Base64 encoding

Face Detection (FaceBlurrer.kt):

suspend fun blurFaces(bitmap: Bitmap, usePixelation: Boolean = false): Bitmap?

Anonymization Methods: 1. Gaussian Blur (default): Stack Blur algorithm, radius 25px - O(n) complexity optimized for mobile - Horizontal + vertical passes 2. Pixelation (alternative): 20px block size - Faster than blur - Stronger anonymization

GDPR Guarantees: - ✅ Mandatory and automatic (cannot be disabled) - ✅ 100% on-device (no network calls) - ✅ Original image never saved after processing - ✅ Blurring applied BEFORE upload to server - ✅ Image rejected if blurring fails (security-first) - ✅ Compatible Android 8+ (API 26+)

Key Implementation Details: - Face detector closed after use to free resources - Bitmaps recycled properly to prevent memory leaks - Detailed logging for debugging (FaceBlurrer.kt:56-96) - Uses coroutines for async processing (suspend function) - Requires kotlinx-coroutines-play-services for ML Kit integration

Dependencies:

implementation("com.google.mlkit:face-detection:16.1.6")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3")

Municipality Without Account

Special handling in IncidentRepository.handleMairieWithoutAccount(): 1. Check local cache (MairieEntity table) for municipality email 2. If not cached, scrape from annuaire-mairie.fr via MairieScraper 3. Cache result for future use 4. Prepare email notification via EmailNotifier

This allows incidents to be reported to municipalities not yet using the platform.

Map Integration

Uses OpenStreetMap (osmdroid) instead of Google Maps: - No API key required - Tile source: TileSourceFactory.MAPNIK - Default center: Menton (43.7737, 7.4951) - Markers use incident type colors

Incident Grouping by Proximity

File: utils/GeoUtils.kt, domain/model/IncidentGroup.kt

Incidents of the same type within 10 meters are visually grouped together:

Grouping Algorithm (utils/GeoUtils.kt:62): - Uses Haversine formula for distance calculation (meter precision) - Groups incidents with: same typeId AND distance < 10m - Offline-first: grouping happens locally on Room data

Geographic Filtering (v1.4.0)

File: ui/screens/home/HomeViewModel.kt:42-74

Incidents displayed are filtered by distance from map center:

Filtering Algorithm: - Radius: 10 km (constant FILTER_RADIUS_METERS = 10000.0) - Method: Haversine distance calculation between map center and each incident - Reactivity: Uses Flow.combine() to merge incident groups with map state - Behavior: Only incidents within 10 km of map center are shown in list

Example:

Map center: Nice (43.7102, 7.2620)
  ↓
Calculate distance for each incident
  ↓
Filter: keep only incidents where distance ≤ 10 km
  ↓
Display: List shows only Nice incidents

This ensures the incident list corresponds to the visible map area.

Data Model (domain/model/IncidentGroup.kt):

data class IncidentGroup(
    val mainIncident: IncidentEntity,
    val similarIncidents: List<IncidentEntity> = emptyList()
) {
    val count: Int  // Total incidents in group
    val isGroup: Boolean  // True if count > 1
    val allIncidents: List<IncidentEntity>  // All incidents
}

Repository (data/repository/IncidentRepository.kt:51):

fun getAllIncidentsGrouped(maxDistance: Double = 10.0): Flow<List<IncidentGroup>>

UI Components: - IncidentGroupCard.kt: Stacked card effect (2 shadows) + red badge counter - IncidentDetailScreen.kt: Full-screen detailed view with: * Layout 1/3 + 2/3 : Carte OSM (1/3 haut) + contenu scrollable (2/3 bas) * Animation de zoom : Zoom progressif de 12 à 17 sur 1200ms lors de l’ouverture * Fond opaque : Surface avec ombre 8dp empêche chevauchement carte/texte * “Primary incident” badge (conditional, if group) * Complete details with icons (type, description, address, date, status) * Horizontal photo gallery (miniatures 100dp, cliquables pour zoom fullscreen) * PhotoZoomDialog : Pinch-to-zoom (1x-5x), pan, fond noir * Secondary incidents list (conditional, if group, sorted by date) - IncidentDetailViewModel.kt: Loads and combines photos from all incidents (clé unique par incident) - MarkerUtils.kt:89: createGroupMarker() - colored marker with red badge counter

HomeScreen Integration (ui/screens/home/HomeScreen.kt): - Map: Grouped markers with badge showing count - List: Stacked cards with visual depth effect - Click group → Full-screen IncidentDetailScreen (replaces ModalBottomSheet) - Click individual → Same full-screen detail view

Key Files: - utils/GeoUtils.kt - Haversine + grouping algorithm - domain/model/IncidentGroup.kt - Group data model - ui/components/IncidentGroupCard.kt - Stacked card UI - ui/screens/home/IncidentDetailScreen.kt - Full-screen detailed view - ui/screens/home/IncidentDetailViewModel.kt - Photo loading logic - utils/MarkerUtils.kt:89 - Group marker rendering - ui/screens/home/HomeViewModel.kt:43 - Group state management - ui/screens/home/HomeScreen.kt:44 - Group UI integration

4 bottom navigation tabs (defined in MainActivity.kt): 1. Accueil (Home): Map + incident list + detailed view (HomeScreen.kt, IncidentDetailScreen.kt) 2. Signaler (Report): Create new incident (ReportScreen.kt) 3. Mes incidents (My Reports): User’s incidents (MyReportsScreen.kt) 4. Profil (Profile): User profile management + app sharing (ProfileScreen.kt)

Important Gotchas

ViewModel Initialization

ViewModels require manual factory creation because they need repository/context:

val homeViewModel = viewModel<HomeViewModel>(
    factory = HomeViewModelFactory(repository, context)
)

// For IncidentDetailViewModel (nested in HomeScreen)
val detailViewModel = viewModel<IncidentDetailViewModel>(
    factory = IncidentDetailViewModelFactory(repository, group)
)

Do NOT use default viewModel() without factory - will crash.

Room Database Migrations

Database version is 3 (updated January 2026). Uses fallbackToDestructiveMigration() in development (drops tables on schema change). For production, implement proper migrations in AppDatabase.kt.

Recent schema changes (v2 → v3): - Renamed table photos to photos_incident for consistency with server schema - All PhotoDao queries updated accordingly

Multipart Upload Format

When uploading incidents with photos via Retrofit, all fields must be RequestBody or MultipartBody.Part:

val typeIdBody = RequestBody.create("text/plain".toMediaTypeOrNull(), typeId.toString())

Do NOT mix plain types with multipart - API will reject.

SyncStatus Enum

Stored as string in Room but used as enum in code. Converter defined in Converters.kt:

@TypeConverter
fun toSyncStatus(value: String): SyncStatus = SyncStatus.valueOf(value)

Incident Type Colors

Colors are hex strings (e.g., “#ef4444”) stored in database. Parse with Color(android.graphics.Color.parseColor(hexString)) in Compose.

Dependencies and Versions

Testing Strategy

Test files should mirror source structure in app/src/test/ and app/src/androidTest/.

Code Conventions

Network Status Detection

utils/NetworkUtils.kt provides isNetworkAvailable(context). Used by: - UI to show offline indicators - Repository to decide between local-only vs sync operations - Not used by WorkManager (handles network constraints internally)

Known Limitations

Recent Features

v1.5.0 - Video Support (January 2026)

Video Compression and Upload

File: utils/VideoCompressor.kt

New video handling capabilities:

Validation: - Max duration: 5 seconds (automatic truncation if exceeded) - Max file size: 5 MB - Automatic validation: dimensions, duration, size - Error messages for invalid videos

Processing:

suspend fun getVideoInfo(context: Context, uri: Uri): VideoInfo
suspend fun compressVideo(context: Context, uri: Uri): File?
suspend fun trimVideoTo5Seconds(context: Context, uri: Uri): File?

Truncation Algorithm (trimVideoTo5Seconds()): - Uses MediaExtractor to read source video - Uses MediaMuxer to write truncated video - Copies all tracks (video + audio) - Stops exactly at 5000ms (5 seconds) - Output format: MP4

UI Enhancements (ReportScreen.kt): - 4 capture buttons: Photo (camera), Gallery (photos), Video 5s (camera), Gallery ▶ (videos) - Video preview with visual indicators: * Blue border (2dp) to distinguish from photos * ▶ icon centered * Duration badge bottom-right (e.g., “3s”) - Separate counter: “2 photo(s) • 1 video(s)”

Database Schema (PhotoEntity.kt):

@Entity(tableName = "photos_incident")
data class PhotoEntity(
    val mediaType: MediaType = MediaType.PHOTO,  // PHOTO or VIDEO
    val durationMs: Long? = null,                // Video duration in ms
    val photoData: String? = null,               // Base64 for upload
    // ... other fields
)

enum class MediaType { PHOTO, VIDEO }

GDPR Warning Dialog (ReportScreen.kt:691-782): - Mandatory warning dialog before video capture/selection - Components: * Warning icon (⚠️, 48dp, orange color) * Title: “⚠️ Important - Protection des données” * Explanatory message: Automatic face blurring unavailable for videos * GDPR recommendations (red background): - Avoid filming identifiable persons - Focus on the problem to report - Respect privacy of passersby * User commitment text (italic) * Buttons: “Annuler” (cancel) or “J’ai compris, continuer” (I understand, continue) - Behavior: Video capture/selection blocked until user explicitly confirms - Implementation: showVideoWarningDialog state + pendingVideoAction callback

Known Limitations: - ⚠️ Face blurring NOT implemented for videos (frame-by-frame processing required) - Future solution: ML Kit Face Detection on each frame (computationally expensive) - Current mitigation: Mandatory GDPR warning dialog before capture (implemented)


v1.3.0 - Incident Detail Screen Redesign (January 2026)

Refonte de l’Écran de Détail d’Incident

File: ui/screens/home/IncidentDetailScreen.kt

Nouvelle mise en page avec animations et UX améliorée :

Layout:

┌─────────────────────┐
│     CARTE (1/3)     │ ← Hauteur fixe avec animation zoom
├─────────────────────┤
│ Titre           X   │
│                     │
│ [Scroll interne]    │ ← Fond opaque (2/3 hauteur)
│ Description...      │
│ Photos (100dp)...   │
└─────────────────────┘

Composants: 1. Carte OSM (1/3 écran): - Animation de zoom : 12 → 17 sur 1200ms - Recentrage automatique pendant l’animation - FastOutSlowInEasing pour transition fluide 2. Surface opaque (2/3 écran): - Fond MaterialTheme.colorScheme.background - Ombre portée 8dp - Empêche chevauchement carte/texte 3. Header interne: Titre + bouton fermer (remplace TopAppBar) 4. Primary Badge (conditionnel): “⭐ Incident principal” si groupe 5. Détails: Type, description, adresse, date, statut 6. Galerie Photos (conditionnelle): - Miniatures 100dp (réduites de 150dp) - Cliquables pour zoom fullscreen - Cache Coil optimisé avec clés uniques 7. PhotoZoomDialog: - Dialog plein écran fond noir - Pinch-to-zoom : 1x à 5x - Pan pour déplacer quand zoomé - Bouton fermer blanc haut-droite 8. Incidents secondaires (conditionnel): Liste si groupe

Fixes Techniques: - ViewModel avec clé unique : key = "incident_detail_${id}" - Galerie n’apparaît que si allPhotos.isNotEmpty() - Retour à l’accueil : dézoome + recentre sur position GPS initiale

Integration: - Tous les incidents (individuels ET groupes) utilisent IncidentDetailScreen - Suppression de IncidentDetailSheet (ModalBottomSheet) - UX cohérente pour tous les cas

App Sharing Feature

File: ui/screens/profile/ProfileScreen.kt:40-60,290-341

Allows users to share the app via Android Intent:

UI: - Card in ProfileScreen with Share icon - Button “Partager via SMS, WhatsApp…” - Styled with tertiaryContainer color scheme

Behavior: - Creates Intent with ACTION_SEND (type: “text/plain”) - Share text includes: * Emoji title “🏘️ Rejoins-moi sur UrbaFix !” * App description * Download link: https://urbafix.fr/download * Call-to-action - Uses Intent.createChooser() to show all available sharing apps (SMS, WhatsApp, Email, Telegram, Messenger, etc.)

Share Message Template:

🏘️ Rejoins-moi sur UrbaFix !

UrbaFix permet de signaler facilement les problèmes dans ton quartier (nids-de-poule, dépôts sauvages, éclairage défaillant, etc.).

📱 Télécharge l'application ici :
https://urbafix.fr/download

Ensemble, améliorons notre cadre de vie !