This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
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)File:
data/remote/RetrofitClient.kt:71-73
val certificatePinner = CertificatePinner.Builder()
.add("urbafix.fr", "sha256/PU4bTrzvP9A7Ft6KDCL+E90q/7+CT/H9oB0iwTy6iS8=")
.build()echo | openssl s_client -connect urbafix.fr:443 -servername urbafix.fr | openssl x509 -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | base64File:
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>File:
data/remote/RetrofitClient.kt:59-63
level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}// 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"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 16See 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
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.
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)
# 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# Run unit tests
./gradlew test
# Run instrumented tests
./gradlew connectedAndroidTest
# Run tests for a specific class
./gradlew test --tests "IncidentRepositoryTest"# 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;# Monitor app logs
adb logcat | grep -E "(IncidentRepository|HomeViewModel|ProfileViewModel|SyncIncidentsWorker)"
# Monitor HTTP requests
adb logcat | grep -E "OkHttp"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
syncStatus = PENDINGsyncStatus = SYNCED and add serverIddata/
├── 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
SyncIncidentsWorker runs every
15 minutes when network connectedBase 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/
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)
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.
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
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)
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 (...)
}SyncIncidentsWorker uses
repository patternsendIncidentToApi()photoDao.getPhotosByIncidentIdAndType(incidentId, PhotoType.DECLARANT)syncIncidentsFromApi()
.distinct() to avoid duplicate
URLsphotoDao.deleteByIncidentId()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")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.
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
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
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)
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.
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
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.
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)Colors are hex strings (e.g., “#ef4444”) stored in database. Parse
with Color(android.graphics.Color.parseColor(hexString)) in
Compose.
ui-test-junit4Test files should mirror source structure in
app/src/test/ and app/src/androidTest/.
viewModelScope in
ViewModels, Dispatchers.IO for database/network@Composable private fun within same fileLog.d/e/w with consistent
TAG (usually class name)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)
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)
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
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 !