Skip to content

Hazard System Architecture

NailLaraqui edited this page Dec 17, 2025 · 5 revisions

This page describes the complete architecture of Warnastrophy's hazard management system, including periodic fetching from the GDACS API, position tracking, and detection of dangerous areas.

Overview

The Hazards system consists of several layers that work together:

  • Data fetching: Periodic retrieval from the GDACS API
  • Position tracking: Monitoring of user movements
  • Geofencing: Detection of entry into dangerous areas
  • Alerting: Notifying the user in case of danger

Global architecture

┌─────────────────────────────────────────────────────────────┐
│                   StateManagerService                       │
│  ┌────────────────────────────────────────────────┐         │
│  │    activeHazardFlow: StateFlow<Hazard?>        │         │
│  │    (Current user alert)                        │         │
│  └────────────────────────────────────────────────┘         │
└──────────────┬──────────────────────┬───────────────────────┘
               │                      │
               ▼                      ▼
    ┌──────────────────┐   ┌──────────────────────┐
    │ HazardsService   │   │ HazardCheckerService │
    │  (Fetching)      │   │   (Geofencing)       │
    └─────────┬────────┘   └──────────┬───────────┘
              │                       │
              ▼                       ▼
    ┌──────────────────┐   ┌──────────────────────┐
    │ HazardsRepository│   │    JTS Library       │
    │   (API Calls)    │   │  (Point-in-Polygon)  │
    └─────────┬────────┘   └──────────────────────┘
              │
              ▼
    ┌──────────────────┐
    │   GDACS API      │
    │  (GeoJSON data)  │
    └──────────────────┘

Hazard data model

data class Hazard(
    val id: Int? = null,                    // Unique ID of the event
    val type: String? = null,               // Type: “EQ,” “DR,” “TC,” etc.
    val description: String? = null,        // Narrative description
    val country: String? = null,            // Affected country
    val date: String? = null,               // Date of event
    val bbox: List<Double>? = null,         // [minLon, minLat, maxLon, maxLat]
    val severity: Double? = null,           // Magnitude/intensity
    val severityUnit: String? = null,       // Unit (e.g., “M” for Magnitude)
    val severityText: String? = null,       // Descriptive text
    val articleUrl: String? = null,         // Article URL
    val alertLevel: Double? = null,         // Priority score
    val centroid: Geometry? = null,         // Center point (JTS)
    val affectedZone: Geometry? = null      // Affected zone (JTS Polygon/MultiPolygon)
)

JTS Geometry :

  • centroid : Point representing the hazard center
  • affectedZone : Polygon or MultiPolygon representing the dangerous zone

1. HazardsServcice - Periodic fetching

Responsabilities

  • Periodically fetches hazards from the GDACS API
  • Calculates the geographical area to monitor around the user
  • Parses GeoJSON data into Hazard objects
  • Gradually completes the detailed data
  • Exposes the status via StateFlow<FetcherState>

Service state

data class FetcherState(
    val hazards: List<Hazard> = emptyList(),
    val isLoading: Boolean = false
)

Fetching cycle

Init
  │
  ▼
┌───────────────────────────────────────┐
│  While service is active:             │
│                                       │
│  1. Get current GPS position          │
│  2. Calculate polygon around user     │
│  3. Fetch partial hazards             │
│  4. Update state with partial data    │
│  5. Complete each hazard sequentially │
│  6. Update state incrementally        │
│  7. Wait for fetch delay              │
│  8. Loop back to step 1               │
└───────────────────────────────────────┘

Cycle implementation

init {
    serviceScope.launch {
        while (isActive) {
            // 1. Current position
            val currPosition = Location(
                latitude = gpsService.positionState.value.position.latitude,
                longitude = gpsService.positionState.value.position.longitude
            )
            
            // 2. Compute polygon (20km x 20 km rectangle)
            val polygon = Location.getPolygon(
                currPosition,
                AppConfig.rectangleHazardZone.first,  // 20000.0 m
                AppConfig.rectangleHazardZone.second  // 20000.0 m
            )
            
            // 3. WKT Convert to WKT format
            val wktPolygon = Location.locationsToWktPolygon(polygon)
            
            try {
                val lastFetch = TimeSource.Monotonic.markNow()
                
                // 4. Partial fetching (fast)
                _fetcherState.value = _fetcherState.value.copy(
                    hazards = fetchHazardsForLocation(wktPolygon),
                    isLoading = false
                )
                
                // 5. Sequentially complete (slow)
                val currentHazards = _fetcherState.value.hazards.toMutableList()
                currentHazards.forEachIndexed { index, hazard ->
                    val completed = repository.completeParsingOf(hazard)
                    if (completed != null) {
                        currentHazards[index] = completed
                        _fetcherState.value = _fetcherState.value.copy(
                            hazards = currentHazards.toList()
                        )
                    }
                }
                
                errorHandler.clearErrorFromScreen(
                    ErrorType.HAZARD_FETCHING_ERROR,
                    Screen.Map
                )
                
                // 6. Wait until next fetch
                delay(AppConfig.gdacsFetchDelay - lastFetch.elapsedNow())
                
            } catch (e: Exception) {
                Log.e("HazardsService", "Error fetching hazards", e)
                errorHandler.addErrorToScreen(
                    ErrorType.HAZARD_FETCHING_ERROR,
                    Screen.Map
                )
                _fetcherState.value = _fetcherState.value.copy(isLoading = false)
            }
        }
    }
}

Progressive update strategy:

  1. Immediate partial fetch → Responsive UI
  2. Incremental completion → UI gradually refines itself

Configuration

object AppConfig {
    val gdacsFetchDelay = 10.minutes        // Delay between fetches
    val gdacsThrottleDelay = 1.seconds      // Throttle between requests
    val rectangleHazardZone = Pair(20000.0, 20000.0)  // 20km x 20km
    val priorDaysFetch = "4"                // 4 days of history
    const val HTTP_TIMEOUT = 15000          // 15s timeout
}

2. HazardsRepository - API access

GDACS endpoints

object Endpoints {
    // List of events in a geographic area
    const val EVENTS_BY_AREA = 
        “https://www.gdacs.org/gdacsapi/api/Events/geteventlist/eventsbyarea”
    
    // Detailed geometry of an event
    const val GET_GEOMETRY = 
        “https://www.gdacs.org/gdacsapi/api/polygons/getgeometry”
    
    // Related news articles
    const val EMM_NEWS_BY_KEY = 
        “https://www.gdacs.org/gdacsapi/api/Emm/getemmnewsbykey”
}

Two-phase fetching

Phase 1 : Partial fetch (fast)

override suspend fun getPartialAreaHazards(
    geometry: String,
    days: String
): List<Hazard> {
    val url = buildUrlAreaHazards(geometry, days)
    val response = httpGet(url)
    
    if (response == null || response.isBlank()) {
        return emptyList()
    }
    
    val jsonObject = JSONObject(response)
    val jsonHazards = jsonObject.getJSONArray("features")
    
    return (0 until jsonHazards.length()).mapNotNull { i ->
        val hazardJson = jsonHazards.getJSONObject(i)
        parsePartialHazard(hazardJson)
    }
}

Retrieved data :

  • ID, type, description
  • Country, date
  • Centroid (central point)
  • Severity, alertLevel
  • ❌ NO : affectedZone, bbox, articleUrl

Phase 2 : Completion (slow)

override suspend fun completeParsingOf(hazard: Hazard): Hazard? {
    return try {
        // 1. Fetch detailed geometry
        val geometryUrl = 
            "${Endpoints.GET_GEOMETRY}?eventtype=${hazard.type}&eventid=${hazard.id}"
        val geometryRes = httpGet(geometryUrl)
        
        // 2. Fetch article URL
        val articleUrl = getHazardArticleUrl(hazard)
        
        // 3. Parse bbox and affectedZone
        val bbox = geometryRes?.let { getBbox(it) }
        val affectedZone = geometryRes?.let { getAffectedZone(it) }
        
        // 4. Return complete hazard
        hazard.copy(
            articleUrl = articleUrl,
            affectedZone = affectedZone,
            bbox = bbox
        )
    } catch (e: Exception) {
        Log.e("HazardsRepository", "Error completing hazard parsing", e)
        null
    }
}

Requests throttling

private var lastApiCall = TimeSource.Monotonic.markNow() - AppConfig.gdacsThrottleDelay

private suspend fun httpGet(urlStr: String): String? {
    // Throttle : wait at least 1 second between requests
    delay(AppConfig.gdacsThrottleDelay - lastApiCall.elapsedNow())
    lastApiCall = TimeSource.Monotonic.markNow()
    
    // Tag network traffic for Android profiling
    val previousTag = TrafficStats.getThreadStatsTag()
    TrafficStats.setThreadStatsTag(socketTag)
    
    return try {
        val url = URL(urlStr)
        val conn = (url.openConnection() as HttpURLConnection).apply {
            requestMethod = "GET"
            setRequestProperty("Accept", "application/json")
            connectTimeout = HTTP_TIMEOUT
            readTimeout = HTTP_TIMEOUT
        }
        
        try {
            when (conn.responseCode) {
                in 200..299 -> {
                    BufferedReader(InputStreamReader(conn.inputStream))
                        .use { it.readText() }
                }
                404 -> null
                else -> {
                    throw IOException("HTTP GET failed with response code")
                }
            }
        } finally {
            conn.disconnect()
        }
    } finally {
        // Restore traffic tag
        if (previousTag == 0) {
            TrafficStats.clearThreadStatsTag()
        } else {
            TrafficStats.setThreadStatsTag(previousTag)
        }
    }
}

Parsing GeoJSON with JTS

object GeometryParser {
    /**
     * Converts a GeoJSON string to a JTS Geometry object.
     * Supports Point, Polygon, MultiPolygon.
     */
    fun convertRawGeoJsonGeometryToJTS(geoJsonGeometryString: String): Geometry? {
        return try {
            val jtsReader = GeoJsonReader()
            jtsReader.read(geoJsonGeometryString)
        } catch (e: ParseException) {
            System.err.println("JTS Parsing Error: ${e.message}")
            null
        }
    }
    
     /**
     * Converts a JTS Geometry to a list of Locations.
     * Used to display hazards on the map.
     */
    fun jtsGeometryToLatLngList(jtsGeometry: Geometry): List<Location>? {
        if (jtsGeometry.isEmpty) return null
        
        val coordinates: Array<Coordinate> = when (jtsGeometry.geometryType) {
            "Point" -> arrayOf((jtsGeometry as Point).coordinate)
            
            "Polygon" -> {
                (jtsGeometry as Polygon).exteriorRing.coordinates
            }
            
            "MultiPolygon" -> {
                val mp = jtsGeometry as MultiPolygon
                if (mp.numGeometries == 0) return null
                val firstPoly = mp.getGeometryN(0) as? Polygon
                firstPoly?.exteriorRing?.coordinates ?: return null
            }
            
            else -> {
                System.err.println("Unsupported Geometry type")
                return null
            }
        }
        
        return coordinates.map { coord ->
            Location(latitude = coord.y, longitude = coord.x)
        }
    }
}

3. HazardCheckerService - Geofencing

Responsibilities

  • Check if the user is in a hazardous area
  • Implement dwell time (minimum residence time)
  • Manage hazard priority (alertLevel)
  • Publish alerts via StateManagerService

Geofencing algorithm

┌─────────────────────────────────────┐
│ For each GPS position:              │
│                                     │
│ 1. Find highest priority hazard     │
│    ├─ BBox check (rapide)           │
│    └─ Point-in-Polygon (précis)     │
│                                     │
│ 2. Clean up inactive hazards        │
│    └─ Cancel pending jobs           │
│                                     │
│ 3. Handle entry for active hazard   │
│    ├─ If new entry:                 │
│    │  ├─ Record entry time          │
│    │  └─ Schedule alert check       │
│    └─ If already inside:            │
│       └─ Keep existing timer        │
│                                     │
│ 4. After dwell time (5s):           │
│    └─ Publish alert                 │
└─────────────────────────────────────┘

Two-step verification

Step 1 : BBox check (fast)

private fun isInsideBBox(lng: Double, lat: Double, bbox: List<Double>): Boolean {
    // bbox = [minLon, minLat, maxLon, maxLat]
    val envelope = Envelope(bbox[0], bbox[2], bbox[1], bbox[3])
    return envelope.contains(lng, lat)
}

Why BBox first ?

  • Extremely fast (4 comparisons)
  • Eliminates 99% of false negatives
  • Avoids costly Point-in-Polygon calculations

Step 2: Point-in-Polygon (precise)

private fun isInsideMultiPolygon(
    lat: Double,
    lng: Double,
    affectedZone: Geometry
): Boolean {
    if (affectedZone.isEmpty) return false
    
    val userCoordinate = Coordinate(lng, lat)
    val userPoint: Point = geometryFactory.createPoint(userCoordinate)
    
    // Robust JTS algorithm
    return affectedZone.contains(userPoint)
}

JTS algorithm :

  • Ray casting algorithm
  • Handles concave polygons
  • Handles MultiPolygon
  • Handles holes in the polygons

Hazards priority

private fun findHighestPriorityActiveHazard(
    userLng: Double,
    userLat: Double
): Hazard? {
    var highestPriorityHazard: Hazard? = null
    
    for (hazard in allHazards) {
        // 1. BBox check
        if (hazard.bbox != null && isInsideBBox(userLng, userLat, hazard.bbox)) {
            
            // 2. Polygon check
            if (hazard.affectedZone == null) continue
            if (isInsideMultiPolygon(userLat, userLng, hazard.affectedZone)) {
                
                // 3. Priority comparison
                if (highestPriorityHazard == null ||
                    (hazard.alertLevel ?: 0.0) > (highestPriorityHazard.alertLevel ?: 0.0)
                ) {
                    highestPriorityHazard = hazard
                }
            }
        }
    }
    
    return highestPriorityHazard
}

Priority logic :

  • If there are multiple hazards simultaneously
  • Select the one with the highest alertLevel
  • Ignore the others

Dwell-time (residence time)

private val HAZARD_TIME_THRESHOLD_MS = 5000L  // 5 seconds

// State : Hazard ID → Entry Time
private val hazardEntryTimes = mutableMapOf<Int, Long>()

// Pending jobs : Hazard ID → Job
private val pendingAlertJobs = mutableMapOf<Int, Job>()

private suspend fun handleHazardEntry(hazard: Hazard) = hazardLock.withLock {
    val hazardId = hazard.id ?: return
    val currentTime = System.currentTimeMillis()
    
    // New entry
    if (!hazardEntryTimes.containsKey(hazardId)) {
        hazardEntryTimes[hazardId] = currentTime
        scheduleAlertCheck(hazard)
    }
    // Already inside : do nothing (timer continues)
}

private suspend fun scheduleAlertCheck(hazard: Hazard) {
    val hazardId = hazard.id ?: return
    
    // Cancel previous job (rare)
    pendingAlertJobs[hazardId]?.cancel()
    
    // Wait for dwell time
    delay(HAZARD_TIME_THRESHOLD_MS)
    
    // Check that the user is still inside
    val currentEntryTime = hazardEntryTimes[hazardId]
    if (currentEntryTime != null) {
        // Time elapsed AND not canceled → Publish alert
        Log.d("HazardChecker", "User has dwelled inside hazard ID: $hazardId")
        StateManagerService.updateActiveHazard(hazard)
    }
    
    // Clean-up
    pendingAlertJobs.remove(hazardId)
}

Why the dwell-time ?

  • Filter false alerts caused by GPS drift
  • Avoid alerts during fast movements
  • Stabilize notifications

Inactive hazards clean-up

private suspend fun cleanUpInactiveHazards(currentActiveHazard: Hazard?) {
    val currentHazardId = currentActiveHazard?.id
    
    hazardLock.withLock {
        // Hazards to be cleaned up = all except current
        val hazardsToClean = hazardEntryTimes.keys - setOfNotNull(currentHazardId)
        
        hazardsToClean.forEach { hazardId ->
            // 1. Cancel the pending job
            pendingAlertJobs[hazardId]?.cancel()
            pendingAlertJobs.remove(hazardId)
            
            // 2. Delete entry time
            hazardEntryTimes.remove(hazardId)
            
            // 3. Clean-up the active alert if necessary
            if (StateManagerService.activeHazardFlow.value?.id == hazardId) {
                StateManagerService.clearActiveAlert()
            }
        }
    }
}

4. StateManagerService - Orchestration

Responsibilities

  • Initialize all services at startup
  • Expose the global activeHazardFlow
  • Coordinate HazardsService and HazardCheckerService
  • Manage the lifecycle of services

Central alert flow

object StateManagerService {
    private val _activeHazardFlow = MutableStateFlow<Hazard?>(null)
    val activeHazardFlow: StateFlow<Hazard?> = _activeHazardFlow.asStateFlow()
    
    /**
     * Called by HazardCheckerService to publish an alert.
     * Thread-safe and instantly notifies all observers.
     */
    fun updateActiveHazard(hazard: Hazard?) {
        if (_activeHazardFlow.value != hazard) {
            _activeHazardFlow.value = hazard
        }
    }
    
    /**
     * Allows the user or system to dismiss the alert.
     */
    fun clearActiveAlert() {
        if (_activeHazardFlow.value != null) {
            _activeHazardFlow.value = null
        }
    }
}

Service coordination

fun init(context: Context) {
    // ... other initializations
    
    // 1. GPS Service
    gpsService = GpsService(locationClient, errorHandler)
    
    // 2. Hazards Service (fetching)
    hazardsService = HazardsService(
        HazardRepositoryProvider.repository,
        gpsService,
        errorHandler
    )
    
    // 3. Hazard Checker (geofencing)
    startHazardSubscription()
}

private fun startHazardSubscription() {
    serviceScope.launch {
        kotlinx.coroutines.flow.combine(
            hazardsService.fetcherState,
            gpsService.positionState
        ) { fetcherState, positionState ->
            fetcherState to positionState
        }
        .collect { (fetcherState, positionState) ->
            // Cancel the previous job
            hazardCheckerJob?.cancel()
            
            // Launch new check
            hazardCheckerJob = hazardCheckerScope.launch {
                HazardCheckerService(
                    fetcherState.hazards,
                    Dispatchers.Main,
                    hazardCheckerScope
                ).checkAndPublishAlert(
                    positionState.position.longitude,
                    positionState.position.latitude
                )
            }
        }
    }
}

Data flow :

HazardsService.fetcherState  ─┐
                              ├─→ combines ─→ HazardCheckerService
GpsService.positionState     ─┘
                                      │
                                      ▼
                            StateManagerService.activeHazardFlow
                                      │
                                      ▼
                              DangerModeService
                                      │
                                      ▼
                                   UI/ViewModel

5. HazardTrackingService - Optional tracking

Responsibilities

  • Monitor significant user movements
  • Trigger a refresh of hazards if movement > threshold
  • Optimize bandwidth consumption

Note: In the current implementation, RefreshHazardsIfMovedService is not provided, so this service is optional/incomplete.

class HazardTrackingService(
    private val gpsService: PositionService? = null,
    private val refreshHazardsIfMoved: RefreshHazardsIfMovedService? = null
) {
    private var isTracking = false
    
    fun startTracking() {
        if (isTracking) return
        isTracking = true
        
        serviceScope.launch {
            gpsService?.positionState?.collectLatest { positionState ->
                val hazardLocation = Location(
                    positionState.position.latitude,
                    positionState.position.longitude
                )
                refreshHazardsIfMoved?.execute(hazardLocation)
            }
        }
    }
    
    fun stopTracking() {
        if (!isTracking) return
        serviceScope.cancel()
        isTracking = false
    }
}

Usage scenarios

Scenario 1 : Application startup

T=0s    MainActivity.onCreate()
        └─→ StateManagerService.init(context)

T=0.1s  Initialisation des services Services initialization
        ├─→ GpsService created
        ├─→ HazardsService created
        └─→ startHazardSubscription()

T=0.5s  GPS obtains first position
        ├─→ positionState emitted
        └─→ Triggers combine flow

T=0.5s  HazardsService starts fetch
        ├─→ Calculate polygon around user
        ├─→ Constructs GDACS URL
        └─→ Partial fetch launched

T=2s    API response received
        ├─→ Parses 15 partial hazards
        ├─→ fetcherState updated
        └─→ UI displays pins on the map

T=2s    Combine flow triggered
        ├─→ HazardCheckerService created
        └─→ checkAndPublishAlert() called

T=2.1s  Geofencing check
        ├─→ For each hazard:
        │   ├─→ BBox check
        │   └─→ [If in BBox] Point-in-Polygon
        └─→ No active hazard → clearActiveAlert()

T=3-15s Progressive completion
        ├─→ For each hazard:
        │   ├─→ Fetch detailed geometry
        │   ├─→ Fetch article URL
        │   └─→ Update hazard in fetcherState
        └─→ UI is refined (displays polygons)

T=10min Next fetch cycle
        └─→ Repeat the process

Scenario 2 : Entering a hazardous area

T=0s    User moves
        └─→ GPS emits new positions

T=5s    User enters bbox of a hazard
        ├─→ checkAndPublishAlert() called
        └─→ BBox check: INSIDE

T=5.1s  Point-in-Polygon check
        ├─→ User Point created
        ├─→ affectedZone.contains(userPoint)
        └─→ Result: TRUE

T=5.1s  findHighestPriorityActiveHazard()
        ├─→ Hazard "Earthquake Mexico" found
        ├─→ alertLevel: 8.5
        └─→ Returns this hazard

T=5.1s  cleanUpInactiveHazards()
        └─→ No previous hazard to clean-up

T=5.1s  handleHazardEntry()
        ├─→ hazardId = 12345
        ├─→ Not in hazardEntryTimes
        ├─→ hazardEntryTimes[12345] = now()
        └─→ scheduleAlertCheck(hazard)

T=5.1s  scheduleAlertCheck() starts
        └─→ delay(5000ms)...

T=10.1s Dwell-time elapsed
        ├─→ User still in hazardEntryTimes
        ├─→ StateManagerService.updateActiveHazard(hazard)
        └─→ activeHazardFlow emits hazard

T=10.1s DangerModeService observes the flow
        ├─→ activatingHazard = hazard
        ├─→ isActive = true
        └─→ Automatically activates the Danger Mode

T=10.2s UI displays alert
        └─→ "⚠️ Earthquake detected in your area"

Scenario 3 : Quick passage (< 5s)

T=0s    User enters in zone
        ├─→ handleHazardEntry()
        ├─→ hazardEntryTimes[12345] = now()
        └─→ scheduleAlertCheck() → delay(5s)

T=2s    User leaves the zone
        ├─→ checkAndPublishAlert()
        ├─→ findHighestPriorityActiveHazard() = null
        └─→ cleanUpInactiveHazards(null)

T=2s    cleanUpInactiveHazards()
        ├─→ hazardsToClean = [12345]
        ├─→ pendingAlertJobs[12345].cancel()
        ├─→ pendingAlertJobs.remove(12345)
        └─→ hazardEntryTimes.remove(12345)

T=5s    delay(5s) ends in scheduleAlertCheck()
        ├─→ hazardEntryTimes[12345] = null
        └─→ No published alert

[No alert - successfuly filtered]

Scenario 4 : Multiple hazards with priority

T=0s    User at the border of 2 hazards
        ├─→ Hazard A: Earthquake, alertLevel = 7.5
        └─→ Hazard B: Flood, alertLevel = 5.0

T=0.1s  findHighestPriorityActiveHazard()
        ├─→ Check Hazard A: INSIDE (alertLevel 7.5)
        ├─→ Check Hazard B: INSIDE (alertLevel 5.0)
        ├─→ Compares: 7.5 > 5.0
        └─→ Return Hazard A (priority)

T=0.1s  handleHazardEntry(Hazard A)
        └─→ Starts timer for Hazard A only

T=0.1s  cleanUpInactiveHazards(Hazard A)
        └─→ Hazard B ignore (non priority)

T=5.1s  Published alert for Hazard A only
        └─→ "⚠️ Earthquake detected"

[Single alert - priority hazard selected]

Optimizations and performances

1. BBox check before Point-in-Polygon

Performance gain:

  • BBox: O(1) - 4 comparisons
  • Point-in-Polygon: O(n) where n = number of vertices

Example:

Hazard with 5000 vertices
- BBox check: ~10 ns
- Point-in-Polygon: ~50 µs

Without BBox: 50 µs × 100 hazards = 5 ms
With BBox: (10 ns × 100) + (50 µs × 2) = 0.1 ms

Gain: 50x faster

2. Two phases fetching

Phase 1: Essential data only

  • 1 API request
  • ~500 KB of data
  • Fast parsing (centroid only)
  • Immediately responsive UI

Phase 2: Progressive details

  • 3 requests per hazard
  • ~2 MB of data per hazard
  • Slow parsing (complex geometries)
  • UI gradually refined

Advantages:

  • Time to first content: <1s vs >30s
  • Improved user experience
  • Optimized bandwidth

3. API requests throttling

private var lastApiCall = TimeSource.Monotonic.markNow() - AppConfig.gdacsThrottleDelay

private suspend fun httpGet(urlStr: String): String? {
    // Guarantees a minimum of 1 second between requests
    delay(AppConfig.gdacsThrottleDelay - lastApiCall.elapsedNow())
    lastApiCall = TimeSource.Monotonic.markNow()
    // ... HTTP request
}

Why ?

  • Respect the limits of the GDACS API
  • Avoiding rate limiting (429 Too Many Requests)
  • Maintaining a good network citizen

4. Android traffic tagging

TrafficStats.setThreadStatsTag(socketTag)
// ... network request
TrafficStats.clearThreadStatsTag()

Advantages :

  • Network profiling in Android Studio
  • StrictMode violations detections
  • Performance issues debugging

Error handling

Error types

enum class ErrorType {
    HAZARD_FETCHING_ERROR,  // API fetch failed
    LOCATION_ERROR,          // GPS unavailable
    LOCATION_NOT_GRANTED,    // Permission missing
    PARSING_ERROR            // GeoJSON parsing failed
}

Processing in HazardsService

try {
    val lastFetch = TimeSource.Monotonic.markNow()
    
    // Fetches and parses
    _fetcherState.value = _fetcherState.value.copy(
        hazards = fetchHazardsForLocation(wktPolygon),
        isLoading = false
    )
    
    // Completion
    // ...
    
    // Success : clean-up error
    errorHandler.clearErrorFromScreen(
        ErrorType.HAZARD_FETCHING_ERROR,
        Screen.Map
    )
    
    delay(AppConfig.gdacsFetchDelay - lastFetch.elapsedNow())
    
} catch (e: Exception) {
    Log.e("HazardsService", "Error fetching hazards", e)
    
    // Error : notify the user
    errorHandler.addErrorToScreen(
        ErrorType.HAZARD_FETCHING_ERROR,
        Screen.Map
    )
    
    _fetcherState.value = _fetcherState.value.copy(isLoading = false)
}

Fallback on error

// In case of API failure, retain previous hazards
catch (e: Exception) {
    Log.e(TAG, "Fetch failed", e)
    errorHandler.addError(ErrorType.HAZARD_FETCHING_ERROR)
    
    // Do not reset fetcherState.hazards
    // The previous data remain displayed
    _fetcherState.value = _fetcherState.value.copy(isLoading = false)
}

Tests

Unit tests

@Test
fun `parsePartialHazard filters non-current hazards`() {
    // Given
    val json = JSONObject("""
        {
            "properties": {
                "iscurrent": false,
                "eventid": 123
            }
        }
    """)
    
    // When
    val hazard = repository.parsePartialHazard(json)
    
    // Then
    assertNull(hazard)
}

@Test
fun `isInsideBBox returns true when inside`() {
    // Given
    val bbox = listOf(-10.0, -5.0, 10.0, 5.0)
    
    // When
    val result = checker.isInsideBBox(0.0, 0.0, bbox)
    
    // Then
    assertTrue(result)
}

@Test
fun `findHighestPriorityActiveHazard selects highest alertLevel`() {
    // Given
    val hazard1 = Hazard(id = 1, alertLevel = 5.0, /* ... */)
    val hazard2 = Hazard(id = 2, alertLevel = 8.0, /* ... */)
    val checker = HazardCheckerService(listOf(hazard1, hazard2))
    
    // When
    val result = checker.findHighestPriorityActiveHazard(lng, lat)
    
    // Then
    assertEquals(2, result?.id)
}

Integration tests

@Test
fun `full hazard detection flow`() = runTest {
    // Given
    val mockRepository = FakeHazardsRepository()
    val mockGps = FakeGpsService()
    val service = HazardsService(mockRepository, mockGps)
    
    // When
    mockGps.setPosition(46.2044, 6.1432)
    advanceTimeBy(100) // Trigger fetch
    
    val hazards = service.fetcherState.first().hazards
    
    // Then
    assertTrue(hazards.isNotEmpty())
    assertEquals("EQ", hazards[0].type)
}

@Test
fun `dwell time prevents quick passage alerts`() = runTest {
    // Given
    val hazard = createTestHazard()
    val checker = HazardCheckerService(listOf(hazard))
    
    // When - Entering zone
    checker.checkAndPublishAlert(userLng, userLat)
    advanceTimeBy(2000) // 2s only
    
    // When - Leaving zone
    checker.checkAndPublishAlert(outsideLng, outsideLat)
    advanceTimeBy(5000) // Dwell time complete
    
    // Then - No published alert
    assertNull(StateManagerService.activeHazardFlow.value)
}

Performance tests

@Test
fun `geofencing check under 10ms for 100 hazards`() {
    // Given
    val hazards = List(100) { createRandomHazard() }
    val checker = HazardCheckerService(hazards)
    
    // When
    val startTime = System.nanoTime()
    runBlocking {
        checker.checkAndPublishAlert(6.1432, 46.2044)
    }
    val elapsed = (System.nanoTime() - startTime) / 1_000_000
    
    // Then
    assertTrue(elapsed < 10, "Check took ${elapsed}ms, expected <10ms")
}

Best practices

1. Always clean-up resources

// ❌ BAD
class MyActivity : Activity() {
    private val hazardsService = HazardsService(...)
    // Service never closed - coroutine leak !
}

// ✅ GOOD
class MyActivity : Activity() {
    private val hazardsService = HazardsService(...)
    
    override fun onDestroy() {
        super.onDestroy()
        hazardsService.close()
    }
}

2. Use StateFlow to observe hazards

// ✅ GOOD
@Composable
fun HazardsMap() {
    val fetcherState by hazardsService.fetcherState.collectAsState()
    
    GoogleMap {
        fetcherState.hazards.forEach { hazard ->
            hazard.centroid?.let { centroid ->
                Marker(position = centroid.toLatLng())
            }
        }
    }
}

3. Handle loading state

// ✅ GOOD
@Composable
fun HazardsList() {
    val fetcherState by hazardsService.fetcherState.collectAsState()
    
    when {
        fetcherState.isLoading -> CircularProgressIndicator()
        fetcherState.hazards.isEmpty() -> Text("No hazards nearby")
        else -> LazyColumn {
            items(fetcherState.hazards) { hazard ->
                HazardItem(hazard)
            }
        }
    }
}

4. Thread-safety for geofencing

// ✅ GOOD - Mutex usage
private val hazardLock = Mutex()

private suspend fun handleHazardEntry(hazard: Hazard) = hazardLock.withLock {
    // Opérations thread-safe
    hazardEntryTimes[hazard.id] = System.currentTimeMillis()
}

5. Log the important events

// ✅ GOOD
private suspend fun scheduleAlertCheck(hazard: Hazard) {
    Log.d("HazardChecker", "Scheduling alert check for hazard ${hazard.id}")
    
    delay(HAZARD_TIME_THRESHOLD_MS)
    
    if (hazardEntryTimes.containsKey(hazard.id)) {
        Log.i("HazardChecker", "Publishing alert for hazard ${hazard.id}")
        StateManagerService.updateActiveHazard(hazard)
    }
}

Complete sequence diagram

User    GPS      Hazards     API      Hazard      State
       Svc      Service     GDACS    Checker     Manager
 │      │          │          │          │           │
 │ Move │          │          │          │           │
 ├─────>│          │          │          │           │
 │      │          │          │          │           │
 │      │ Position │          │          │           │
 │      │  Update  │          │          │           │
 │      ├─────────>│          │          │           │
 │      │          │          │          │           │
 │      │          │ Calc     │          │           │
 │      │          │ Polygon  │          │           │
 │      │          │          │          │           │
 │      │          │ HTTP GET │          │           │
 │      │          ├─────────>│          │           │
 │      │          │          │          │           │
 │      │          │ GeoJSON  │          │           │
 │      │          │<─────────┤          │           │
 │      │          │          │          │           │
 │      │          │ Parse    │          │           │
 │      │          │ Partial  │          │           │
 │      │          │          │          │           │
 │      │          │ Emit     │          │           │
 │      │          │ State    │          │           │
 │      │          │          │          │           │
 │      │          │ Complete │          │           │
 │      │          │ (3 calls)│          │           │
 │      │          ├─────────>│          │           │
 │      │          │<─────────┤          │           │
 │      │          │          │          │           │
 │      │          │ Emit     │          │           │
 │      │          │ Complete │          │           │
 │      │          │          │          │           │
 │      │ Position │          │          │           │
 │      │  Update  │          │          │           │
 │      ├─────────────────────┼─────────>│           │
 │      │          │          │          │           │
 │      │          │          │   BBox   │           │
 │      │          │          │   Check  │           │
 │      │          │          │          │           │
 │      │          │          │  Point   │           │
 │      │          │          │    in    │           │
 │      │          │          │ Polygon  │           │
 │      │          │          │          │           │
 │      │          │          │  INSIDE  │           │
 │      │          │          │          │           │
 │      │          │          │ Record   │           │
 │      │          │          │  Entry   │           │
 │      │          │          │          │           │
 │      │          │          │ delay(5s)│           │
 │      │          │          │          │           │
 │      │          │          │ Publish  │           │
 │      │          │          │  Alert   │           │
 │      │          │          ├─────────────────────>│
 │      │          │          │          │           │
 │      │          │          │          │ Notify    │
 │      │          │          │          │ DangerMode│
 │      │          │          │          │           │
 │<──────────────────────────────────────┴───────────┘
 │      │          │  ⚠️ ALERT DISPLAYED │           │

Summary

The Warnastrophy Hazards system is a sophisticated architecture that:

  1. Periodically fetches data from the GDACS API with throttling
  2. Parses in two phases for a responsive UI
  3. Uses JTS for robust geometric calculations
  4. Implements geofencing with BBox check + Point-in-Polygon
  5. Manages priority of multiple hazards via alertLevel
  6. Applies dwell-time to filter GPS drift
  7. Coordinates via StateFlow for a reactive architecture
  8. Optimizes performance with efficient algorithms

The system ensures that the user is reliably and consistently alerted when entering a hazardous area, while minimizing false alerts and optimizing network and CPU resource usage.

Resources

Clone this wiki locally