Improve error visibility and eliminate silent failures

- LocationProvider: close Flow with SecurityException when permission
  is missing (instead of silently completing), log trySend failures,
  handle lastLocation failure callback
- loadData(): wrap in top-level try-catch so database errors don't
  leave the app in a broken loading state
- Cache retry button: show "Waiting for GPS" toast when location is
  null instead of silently doing nothing
- selectShelterByData: use NaN for distance/bearing when no GPS fix
  yet, so UI shows "—" instead of misleading "0 m"
- DistanceUtils.formatDistance: handle NaN gracefully
- GeoJSON parser: require valid lokalId (primary key) — reject
  shelters with missing ID to prevent DB collisions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-08 17:56:54 +01:00
commit c129de0a12
4 changed files with 92 additions and 55 deletions

View file

@ -193,65 +193,74 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
} }
binding.cacheRetryButton.setOnClickListener { binding.cacheRetryButton.setOnClickListener {
currentLocation?.let { loc -> val loc = currentLocation
if (isNetworkAvailable()) { if (loc == null) {
startCaching(loc.latitude, loc.longitude) Toast.makeText(this, R.string.status_no_location, Toast.LENGTH_SHORT).show()
} else { } else if (!isNetworkAvailable()) {
Toast.makeText(this, R.string.error_download_failed, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.error_download_failed, Toast.LENGTH_SHORT).show()
} } else {
startCaching(loc.latitude, loc.longitude)
} }
} }
} }
private fun loadData() { private fun loadData() {
lifecycleScope.launch { lifecycleScope.launch {
val hasData = repository.hasCachedData() try {
val hasData = repository.hasCachedData()
if (!hasData) { if (!hasData) {
if (!isNetworkAvailable()) { if (!isNetworkAvailable()) {
binding.statusText.text = getString(R.string.error_no_data_offline) binding.statusText.text = getString(R.string.error_no_data_offline)
return@launch return@launch
}
showLoading(getString(R.string.loading_shelters))
val success = repository.refreshData()
hideLoading()
if (!success) {
binding.statusText.text = getString(R.string.error_download_failed)
return@launch
}
}
// Observe shelter data reactively
launch {
try {
repository.getAllShelters().collectLatest { shelters ->
allShelters = shelters
binding.statusText.text = getString(R.string.status_shelters_loaded, shelters.size)
updateShelterMarkers()
currentLocation?.let { updateNearestShelters(it) }
} }
} catch (e: CancellationException) { showLoading(getString(R.string.loading_shelters))
throw e
} catch (e: Exception) {
Log.e(TAG, "Error observing shelter data", e)
binding.statusText.text = getString(R.string.error_download_failed)
}
}
// Request location and start updates
requestLocationPermission()
// Check for stale data in background
if (hasData && repository.isDataStale() && isNetworkAvailable()) {
launch {
val success = repository.refreshData() val success = repository.refreshData()
if (success) { hideLoading()
Toast.makeText(this@MainActivity, R.string.update_success, Toast.LENGTH_SHORT).show()
} else { if (!success) {
Toast.makeText(this@MainActivity, R.string.update_failed, Toast.LENGTH_SHORT).show() binding.statusText.text = getString(R.string.error_download_failed)
return@launch
} }
} }
// Observe shelter data reactively
launch {
try {
repository.getAllShelters().collectLatest { shelters ->
allShelters = shelters
binding.statusText.text = getString(R.string.status_shelters_loaded, shelters.size)
updateShelterMarkers()
currentLocation?.let { updateNearestShelters(it) }
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "Error observing shelter data", e)
binding.statusText.text = getString(R.string.error_download_failed)
}
}
// Request location and start updates
requestLocationPermission()
// Check for stale data in background
if (hasData && repository.isDataStale() && isNetworkAvailable()) {
launch {
val success = repository.refreshData()
if (success) {
Toast.makeText(this@MainActivity, R.string.update_success, Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this@MainActivity, R.string.update_failed, Toast.LENGTH_SHORT).show()
}
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize shelter data", e)
hideLoading()
binding.statusText.text = getString(R.string.error_download_failed)
} }
} }
} }
@ -384,7 +393,12 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
) )
) )
} else { } else {
ShelterWithDistance(shelter = shelter, distanceMeters = 0.0, bearingDegrees = 0.0) // No GPS yet — use NaN to signal "unknown distance"
ShelterWithDistance(
shelter = shelter,
distanceMeters = Double.NaN,
bearingDegrees = Double.NaN
)
} }
userSelectedShelter = true userSelectedShelter = true

View file

@ -91,6 +91,14 @@ object ShelterGeoJsonParser {
continue continue
} }
// Require a valid primary key — without it shelters can collide in the DB
val lokalId = properties.optString("lokalId", null)
if (lokalId.isNullOrBlank()) {
Log.w(TAG, "Skipping shelter at index $i: missing lokalId")
skipped++
continue
}
val plasser = properties.optInt("plasser", 0) val plasser = properties.optInt("plasser", 0)
if (plasser < 0) { if (plasser < 0) {
Log.w(TAG, "Skipping shelter at index $i: negative capacity ($plasser)") Log.w(TAG, "Skipping shelter at index $i: negative capacity ($plasser)")
@ -100,7 +108,7 @@ object ShelterGeoJsonParser {
shelters.add( shelters.add(
Shelter( Shelter(
lokalId = properties.optString("lokalId", "unknown-$i"), lokalId = lokalId,
romnr = properties.optInt("romnr", 0), romnr = properties.optInt("romnr", 0),
plasser = plasser, plasser = plasser,
adresse = properties.optString("adresse", ""), adresse = properties.optString("adresse", ""),

View file

@ -20,6 +20,9 @@ import kotlinx.coroutines.flow.callbackFlow
/** /**
* Provides GPS location updates using the Fused Location Provider. * Provides GPS location updates using the Fused Location Provider.
* Emits location updates as a Flow for reactive consumption. * Emits location updates as a Flow for reactive consumption.
*
* Closes the Flow with a SecurityException if location permission is not granted,
* so callers can detect and handle this failure explicitly.
*/ */
class LocationProvider(private val context: Context) { class LocationProvider(private val context: Context) {
@ -34,22 +37,28 @@ class LocationProvider(private val context: Context) {
/** /**
* Stream of location updates. Emits the last known location first (if available), * Stream of location updates. Emits the last known location first (if available),
* then continuous updates. * then continuous updates. Throws SecurityException if permission is not granted.
*/ */
fun locationUpdates(): Flow<Location> = callbackFlow { fun locationUpdates(): Flow<Location> = callbackFlow {
if (!hasLocationPermission()) { if (!hasLocationPermission()) {
Log.w(TAG, "Location permission not granted") close(SecurityException("Location permission not granted"))
close()
return@callbackFlow return@callbackFlow
} }
// Try to get last known location for immediate display // Try to get last known location for immediate display
try { try {
fusedClient.lastLocation.addOnSuccessListener { location -> fusedClient.lastLocation
if (location != null) { .addOnSuccessListener { location ->
trySend(location) if (location != null) {
val result = trySend(location)
if (result.isFailure) {
Log.w(TAG, "Failed to emit last known location")
}
}
}
.addOnFailureListener { e ->
Log.w(TAG, "Could not get last known location", e)
} }
}
} catch (e: SecurityException) { } catch (e: SecurityException) {
Log.e(TAG, "SecurityException getting last location", e) Log.e(TAG, "SecurityException getting last location", e)
} }
@ -64,7 +73,12 @@ class LocationProvider(private val context: Context) {
val callback = object : LocationCallback() { val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) { override fun onLocationResult(result: LocationResult) {
result.lastLocation?.let { trySend(it) } result.lastLocation?.let { location ->
val sendResult = trySend(location)
if (sendResult.isFailure) {
Log.w(TAG, "Failed to emit location update")
}
}
} }
} }
@ -76,7 +90,7 @@ class LocationProvider(private val context: Context) {
) )
} catch (e: SecurityException) { } catch (e: SecurityException) {
Log.e(TAG, "SecurityException requesting location updates", e) Log.e(TAG, "SecurityException requesting location updates", e)
close() close(e)
return@callbackFlow return@callbackFlow
} }

View file

@ -38,6 +38,7 @@ object DistanceUtils {
* Format distance for display: meters if <1km, otherwise km with one decimal. * Format distance for display: meters if <1km, otherwise km with one decimal.
*/ */
fun formatDistance(meters: Double): String { fun formatDistance(meters: Double): String {
if (meters.isNaN()) return ""
return if (meters < 1000) { return if (meters < 1000) {
"${meters.toInt()} m" "${meters.toInt()} m"
} else { } else {