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:
parent
55761718e1
commit
c129de0a12
4 changed files with 92 additions and 55 deletions
|
|
@ -193,65 +193,74 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
}
|
||||
|
||||
binding.cacheRetryButton.setOnClickListener {
|
||||
currentLocation?.let { loc ->
|
||||
if (isNetworkAvailable()) {
|
||||
startCaching(loc.latitude, loc.longitude)
|
||||
} else {
|
||||
Toast.makeText(this, R.string.error_download_failed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
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 {
|
||||
val hasData = repository.hasCachedData()
|
||||
try {
|
||||
val hasData = repository.hasCachedData()
|
||||
|
||||
if (!hasData) {
|
||||
if (!isNetworkAvailable()) {
|
||||
binding.statusText.text = getString(R.string.error_no_data_offline)
|
||||
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) }
|
||||
if (!hasData) {
|
||||
if (!isNetworkAvailable()) {
|
||||
binding.statusText.text = getString(R.string.error_no_data_offline)
|
||||
return@launch
|
||||
}
|
||||
} 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 {
|
||||
showLoading(getString(R.string.loading_shelters))
|
||||
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()
|
||||
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) {
|
||||
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 {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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", ""),
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
if (location != null) {
|
||||
trySend(location)
|
||||
fusedClient.lastLocation
|
||||
.addOnSuccessListener { 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) {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue