-
Notifications
You must be signed in to change notification settings - Fork 0
Hazard System Architecture
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.
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
┌─────────────────────────────────────────────────────────────┐
│ StateManagerService │
│ ┌────────────────────────────────────────────────┐ │
│ │ activeHazardFlow: StateFlow<Hazard?> │ │
│ │ (Current user alert) │ │
│ └────────────────────────────────────────────────┘ │
└──────────────┬──────────────────────┬───────────────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────────┐
│ HazardsService │ │ HazardCheckerService │
│ (Fetching) │ │ (Geofencing) │
└─────────┬────────┘ └──────────┬───────────┘
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────────┐
│ HazardsRepository│ │ JTS Library │
│ (API Calls) │ │ (Point-in-Polygon) │
└─────────┬────────┘ └──────────────────────┘
│
▼
┌──────────────────┐
│ GDACS API │
│ (GeoJSON data) │
└──────────────────┘
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:PolygonorMultiPolygonrepresenting the dangerous zone
- 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>
data class FetcherState(
val hazards: List<Hazard> = emptyList(),
val isLoading: Boolean = false
)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 │
└───────────────────────────────────────┘
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:
- Immediate partial fetch → Responsive UI
- Incremental completion → UI gradually refines itself
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
}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”
}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
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
}
}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)
}
}
}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)
}
}
}- Check if the user is in a hazardous area
- Implement dwell time (minimum residence time)
- Manage hazard priority (alertLevel)
- Publish alerts via
StateManagerService
┌─────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────┘
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
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
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
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
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()
}
}
}
}- Initialize all services at startup
- Expose the global
activeHazardFlow - Coordinate HazardsService and HazardCheckerService
- Manage the lifecycle of services
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
}
}
}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
- 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
}
}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
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"
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]
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]
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
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
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
TrafficStats.setThreadStatsTag(socketTag)
// ... network request
TrafficStats.clearThreadStatsTag()Advantages :
- Network profiling in Android Studio
- StrictMode violations detections
- Performance issues debugging
enum class ErrorType {
HAZARD_FETCHING_ERROR, // API fetch failed
LOCATION_ERROR, // GPS unavailable
LOCATION_NOT_GRANTED, // Permission missing
PARSING_ERROR // GeoJSON parsing failed
}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)
}// 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)
}@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)
}@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)
}@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")
}// ❌ 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()
}
}// ✅ GOOD
@Composable
fun HazardsMap() {
val fetcherState by hazardsService.fetcherState.collectAsState()
GoogleMap {
fetcherState.hazards.forEach { hazard ->
hazard.centroid?.let { centroid ->
Marker(position = centroid.toLatLng())
}
}
}
}// ✅ 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)
}
}
}
}// ✅ GOOD - Mutex usage
private val hazardLock = Mutex()
private suspend fun handleHazardEntry(hazard: Hazard) = hazardLock.withLock {
// Opérations thread-safe
hazardEntryTimes[hazard.id] = System.currentTimeMillis()
}// ✅ 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)
}
}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 │ │
The Warnastrophy Hazards system is a sophisticated architecture that:
- Periodically fetches data from the GDACS API with throttling
- Parses in two phases for a responsive UI
- Uses JTS for robust geometric calculations
- Implements geofencing with BBox check + Point-in-Polygon
- Manages priority of multiple hazards via alertLevel
- Applies dwell-time to filter GPS drift
- Coordinates via StateFlow for a reactive architecture
- 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.