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,18 +193,20 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
}
binding.cacheRetryButton.setOnClickListener {
currentLocation?.let { loc ->
if (isNetworkAvailable()) {
startCaching(loc.latitude, loc.longitude)
} else {
val loc = currentLocation
if (loc == null) {
Toast.makeText(this, R.string.status_no_location, Toast.LENGTH_SHORT).show()
} else if (!isNetworkAvailable()) {
Toast.makeText(this, R.string.error_download_failed, Toast.LENGTH_SHORT).show()
}
} else {
startCaching(loc.latitude, loc.longitude)
}
}
}
private fun loadData() {
lifecycleScope.launch {
try {
val hasData = repository.hasCachedData()
if (!hasData) {
@ -253,6 +255,13 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
}
}
}
} 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 {
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

View file

@ -91,6 +91,14 @@ object ShelterGeoJsonParser {
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)
if (plasser < 0) {
Log.w(TAG, "Skipping shelter at index $i: negative capacity ($plasser)")
@ -100,7 +108,7 @@ object ShelterGeoJsonParser {
shelters.add(
Shelter(
lokalId = properties.optString("lokalId", "unknown-$i"),
lokalId = lokalId,
romnr = properties.optInt("romnr", 0),
plasser = plasser,
adresse = properties.optString("adresse", ""),

View file

@ -20,6 +20,9 @@ import kotlinx.coroutines.flow.callbackFlow
/**
* Provides GPS location updates using the Fused Location Provider.
* 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) {
@ -34,22 +37,28 @@ class LocationProvider(private val context: Context) {
/**
* 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 {
if (!hasLocationPermission()) {
Log.w(TAG, "Location permission not granted")
close()
close(SecurityException("Location permission not granted"))
return@callbackFlow
}
// Try to get last known location for immediate display
try {
fusedClient.lastLocation.addOnSuccessListener { location ->
fusedClient.lastLocation
.addOnSuccessListener { location ->
if (location != null) {
trySend(location)
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) {
Log.e(TAG, "SecurityException getting last location", e)
}
@ -64,7 +73,12 @@ class LocationProvider(private val context: Context) {
val callback = object : LocationCallback() {
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) {
Log.e(TAG, "SecurityException requesting location updates", e)
close()
close(e)
return@callbackFlow
}

View file

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