Improve lifecycle management, compass reliability, and accessibility

- Use repeatOnLifecycle(STARTED) for location updates so GPS stops
  when Activity is paused (battery conservation)
- Pair enableMyLocation/disableMyLocation with onResume/onPause
- Add low-pass filter for accelerometer+magnetometer compass fallback
  to reduce arrow jitter
- Detect missing compass sensors and show warning instead of frozen
  arrow
- Monitor compass accuracy via onAccuracyChanged and indicate
  degraded readings
- Show permission rationale dialog using existing i18n strings
- Guide user to Settings when location permission is permanently
  denied
- Check cacheMapArea return value and show banner on failure
- Add error handling around shelter Flow and location Flow collection
- Add contentDescription to direction arrow views for screen readers
- Use safe casts for system services (as? instead of as)
- Use explicit dark theme parent (Theme.Material3.Dark.NoActionBar)
  instead of DayNight with hardcoded dark colors
- Add error_no_compass and direction_arrow_description strings in
  en/nb/nn

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-08 17:54:06 +01:00
commit 55761718e1
5 changed files with 186 additions and 57 deletions

View file

@ -2,6 +2,7 @@ package no.naiv.tilfluktsrom
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.hardware.Sensor import android.hardware.Sensor
import android.hardware.SensorEvent import android.hardware.SensorEvent
@ -10,17 +11,22 @@ import android.hardware.SensorManager
import android.location.Location import android.location.Location
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.provider.Settings
import android.util.Log import android.util.Log
import android.view.View import android.view.View
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import no.naiv.tilfluktsrom.data.MapCacheManager import no.naiv.tilfluktsrom.data.MapCacheManager
@ -50,7 +56,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
private lateinit var repository: ShelterRepository private lateinit var repository: ShelterRepository
private lateinit var locationProvider: LocationProvider private lateinit var locationProvider: LocationProvider
private lateinit var mapCacheManager: MapCacheManager private lateinit var mapCacheManager: MapCacheManager
private lateinit var sensorManager: SensorManager private var sensorManager: SensorManager? = null
private lateinit var shelterAdapter: ShelterListAdapter private lateinit var shelterAdapter: ShelterListAdapter
private var myLocationOverlay: MyLocationNewOverlay? = null private var myLocationOverlay: MyLocationNewOverlay? = null
@ -59,12 +65,14 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
private var nearestShelters: List<ShelterWithDistance> = emptyList() private var nearestShelters: List<ShelterWithDistance> = emptyList()
private var deviceHeading = 0f private var deviceHeading = 0f
private var isCompassMode = false private var isCompassMode = false
private var locationJob: Job? = null
private var cachingJob: Job? = null private var cachingJob: Job? = null
// Map from shelter lokalId to its map marker, for icon swapping on selection // Map from shelter lokalId to its map marker, for icon swapping on selection
private var shelterMarkerMap: MutableMap<String, Marker> = mutableMapOf() private var shelterMarkerMap: MutableMap<String, Marker> = mutableMapOf()
private var highlightedMarkerId: String? = null private var highlightedMarkerId: String? = null
// Whether a compass sensor is available on this device
private var hasCompassSensor = false
// The currently selected shelter — can be any shelter, not just one from nearestShelters // The currently selected shelter — can be any shelter, not just one from nearestShelters
private var selectedShelter: ShelterWithDistance? = null private var selectedShelter: ShelterWithDistance? = null
// When true, location updates won't auto-select the nearest shelter // When true, location updates won't auto-select the nearest shelter
@ -80,10 +88,29 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
if (fineGranted || coarseGranted) { if (fineGranted || coarseGranted) {
startLocationUpdates() startLocationUpdates()
} else {
// Check if user permanently denied (don't show rationale = permanently denied)
val shouldShowRationale = ActivityCompat.shouldShowRequestPermissionRationale(
this, Manifest.permission.ACCESS_FINE_LOCATION
)
if (!shouldShowRationale) {
// Permission permanently denied — guide user to settings
AlertDialog.Builder(this)
.setTitle(R.string.permission_location_title)
.setMessage(R.string.permission_denied)
.setPositiveButton(android.R.string.ok) { _, _ ->
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", packageName, null)
}
startActivity(intent)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
} else { } else {
Toast.makeText(this, R.string.permission_denied, Toast.LENGTH_LONG).show() Toast.makeText(this, R.string.permission_denied, Toast.LENGTH_LONG).show()
} }
} }
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -93,7 +120,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
repository = ShelterRepository(this) repository = ShelterRepository(this)
locationProvider = LocationProvider(this) locationProvider = LocationProvider(this)
mapCacheManager = MapCacheManager(this) mapCacheManager = MapCacheManager(this)
sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager sensorManager = getSystemService(Context.SENSOR_SERVICE) as? SensorManager
setupMap() setupMap()
setupShelterList() setupShelterList()
@ -122,9 +149,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
// Add user location overlay // Add user location overlay
myLocationOverlay = MyLocationNewOverlay( myLocationOverlay = MyLocationNewOverlay(
GpsMyLocationProvider(this@MainActivity), this GpsMyLocationProvider(this@MainActivity), this
).apply { )
enableMyLocation()
}
overlays.add(myLocationOverlay) overlays.add(myLocationOverlay)
} }
} }
@ -137,7 +162,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
} }
binding.shelterList.apply { binding.shelterList.apply {
layoutManager = LinearLayoutManager(this@MainActivity) layoutManager = androidx.recyclerview.widget.LinearLayoutManager(this@MainActivity)
adapter = shelterAdapter adapter = shelterAdapter
} }
} }
@ -199,12 +224,19 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
// Observe shelter data reactively // Observe shelter data reactively
launch { launch {
try {
repository.getAllShelters().collectLatest { shelters -> repository.getAllShelters().collectLatest { shelters ->
allShelters = shelters allShelters = shelters
binding.statusText.text = getString(R.string.status_shelters_loaded, shelters.size) binding.statusText.text = getString(R.string.status_shelters_loaded, shelters.size)
updateShelterMarkers() updateShelterMarkers()
currentLocation?.let { updateNearestShelters(it) } 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 // Request location and start updates
@ -216,6 +248,8 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
val success = repository.refreshData() val success = repository.refreshData()
if (success) { if (success) {
Toast.makeText(this@MainActivity, R.string.update_success, Toast.LENGTH_SHORT).show() 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()
} }
} }
} }
@ -225,7 +259,30 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
private fun requestLocationPermission() { private fun requestLocationPermission() {
if (locationProvider.hasLocationPermission()) { if (locationProvider.hasLocationPermission()) {
startLocationUpdates() startLocationUpdates()
return
}
// Show rationale dialog if needed, then request
if (ActivityCompat.shouldShowRequestPermissionRationale(
this, Manifest.permission.ACCESS_FINE_LOCATION
)
) {
AlertDialog.Builder(this)
.setTitle(R.string.permission_location_title)
.setMessage(R.string.permission_location_message)
.setPositiveButton(android.R.string.ok) { _, _ ->
launchPermissionRequest()
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
Toast.makeText(this, R.string.permission_denied, Toast.LENGTH_LONG).show()
}
.show()
} else { } else {
launchPermissionRequest()
}
}
private fun launchPermissionRequest() {
locationPermissionRequest.launch( locationPermissionRequest.launch(
arrayOf( arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION,
@ -233,11 +290,12 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
) )
) )
} }
}
private fun startLocationUpdates() { private fun startLocationUpdates() {
locationJob?.cancel() // Use repeatOnLifecycle(STARTED) so GPS stops when Activity is paused
locationJob = lifecycleScope.launch { lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
try {
locationProvider.locationUpdates().collectLatest { location -> locationProvider.locationUpdates().collectLatest { location ->
currentLocation = location currentLocation = location
updateNearestShelters(location) updateNearestShelters(location)
@ -256,6 +314,13 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
} }
} }
} }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "Location updates failed", e)
binding.statusText.text = getString(R.string.status_no_location)
}
}
} }
} }
@ -357,14 +422,21 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
R.string.shelter_capacity, selected.shelter.plasser R.string.shelter_capacity, selected.shelter.plasser
) + " - " + distanceText ) + " - " + distanceText
// Update mini arrow in bottom sheet // Update direction arrows with accessibility descriptions
val bearing = selected.bearingDegrees.toFloat() val bearing = selected.bearingDegrees.toFloat()
binding.miniArrow.setDirection(bearing - deviceHeading) val arrowAngle = bearing - deviceHeading
binding.miniArrow.setDirection(arrowAngle)
binding.miniArrow.contentDescription = getString(
R.string.direction_arrow_description, distanceText
)
// Update compass view // Update compass view
binding.compassDistanceText.text = distanceText binding.compassDistanceText.text = distanceText
binding.compassAddressText.text = selected.shelter.adresse binding.compassAddressText.text = selected.shelter.adresse
binding.directionArrow.setDirection(bearing - deviceHeading) binding.directionArrow.setDirection(arrowAngle)
binding.directionArrow.contentDescription = getString(
R.string.direction_arrow_description, distanceText
)
// Emphasize the selected marker on the map // Emphasize the selected marker on the map
highlightSelectedMarker(selected.shelter.lokalId) highlightSelectedMarker(selected.shelter.lokalId)
@ -465,14 +537,18 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
binding.loadingButtonRow.visibility = View.GONE binding.loadingButtonRow.visibility = View.GONE
cachingJob = lifecycleScope.launch { cachingJob = lifecycleScope.launch {
mapCacheManager.cacheMapArea( val success = mapCacheManager.cacheMapArea(
binding.mapView, latitude, longitude binding.mapView, latitude, longitude
) { progress -> ) { progress ->
binding.loadingText.text = getString(R.string.loading_map) + binding.loadingText.text = getString(R.string.loading_map) +
" (${(progress * 100).toInt()}%)" " (${(progress * 100).toInt()}%)"
} }
hideLoading() hideLoading()
if (success) {
binding.statusText.text = getString(R.string.status_shelters_loaded, allShelters.size) binding.statusText.text = getString(R.string.status_shelters_loaded, allShelters.size)
} else {
showNoCacheBanner()
}
} }
} }
@ -509,7 +585,8 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
} }
private fun isNetworkAvailable(): Boolean { private fun isNetworkAvailable(): Boolean {
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return false
val network = cm.activeNetwork ?: return false val network = cm.activeNetwork ?: return false
val caps = cm.getNetworkCapabilities(network) ?: return false val caps = cm.getNetworkCapabilities(network) ?: return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
@ -520,18 +597,28 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
binding.mapView.onResume() binding.mapView.onResume()
myLocationOverlay?.enableMyLocation()
// Register for rotation vector (best compass source) val sm = sensorManager ?: return
val rotationSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
// Try rotation vector first (best compass source)
val rotationSensor = sm.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
if (rotationSensor != null) { if (rotationSensor != null) {
sensorManager.registerListener(this, rotationSensor, SensorManager.SENSOR_DELAY_UI) sm.registerListener(this, rotationSensor, SensorManager.SENSOR_DELAY_UI)
hasCompassSensor = true
} else { } else {
// Fallback to accelerometer + magnetometer // Fallback to accelerometer + magnetometer
sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)?.let { val accel = sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_UI) val mag = sm.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)
} if (accel != null && mag != null) {
sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)?.let { sm.registerListener(this, accel, SensorManager.SENSOR_DELAY_UI)
sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_UI) sm.registerListener(this, mag, SensorManager.SENSOR_DELAY_UI)
hasCompassSensor = true
Log.w(TAG, "Using accelerometer+magnetometer fallback for compass")
} else {
hasCompassSensor = false
Log.e(TAG, "No compass sensors available on this device")
binding.compassAddressText.text = getString(R.string.error_no_compass)
} }
} }
} }
@ -539,7 +626,8 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
binding.mapView.onPause() binding.mapView.onPause()
sensorManager.unregisterListener(this) myLocationOverlay?.disableMyLocation()
sensorManager?.unregisterListener(this)
} }
private val gravity = FloatArray(3) private val gravity = FloatArray(3)
@ -557,16 +645,23 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
updateDirectionArrows() updateDirectionArrows()
} }
Sensor.TYPE_ACCELEROMETER -> { Sensor.TYPE_ACCELEROMETER -> {
System.arraycopy(event.values, 0, gravity, 0, 3) lowPassFilter(event.values, gravity)
updateFromAccelMag() updateFromAccelMag()
} }
Sensor.TYPE_MAGNETIC_FIELD -> { Sensor.TYPE_MAGNETIC_FIELD -> {
System.arraycopy(event.values, 0, geomagnetic, 0, 3) lowPassFilter(event.values, geomagnetic)
updateFromAccelMag() updateFromAccelMag()
} }
} }
} }
/** Low-pass filter to smooth noisy accelerometer/magnetometer data. */
private fun lowPassFilter(input: FloatArray, output: FloatArray, alpha: Float = 0.25f) {
for (i in input.indices) {
output[i] = output[i] + alpha * (input[i] - output[i])
}
}
private fun updateFromAccelMag() { private fun updateFromAccelMag() {
val r = FloatArray(9) val r = FloatArray(9)
if (SensorManager.getRotationMatrix(r, null, gravity, geomagnetic)) { if (SensorManager.getRotationMatrix(r, null, gravity, geomagnetic)) {
@ -587,6 +682,28 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
} }
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) { override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
// No-op if (sensor?.type == Sensor.TYPE_MAGNETIC_FIELD ||
sensor?.type == Sensor.TYPE_ROTATION_VECTOR
) {
when (accuracy) {
SensorManager.SENSOR_STATUS_UNRELIABLE,
SensorManager.SENSOR_STATUS_ACCURACY_LOW -> {
Log.w(TAG, "Compass accuracy degraded: $accuracy")
binding.compassAddressText.let { tv ->
val current = selectedShelter?.shelter?.adresse ?: ""
if (!current.contains("")) {
tv.text = "$current"
}
}
}
SensorManager.SENSOR_STATUS_ACCURACY_MEDIUM,
SensorManager.SENSOR_STATUS_ACCURACY_HIGH -> {
// Restore normal display when accuracy improves
selectedShelter?.let { selected ->
binding.compassAddressText.text = selected.shelter.adresse
}
}
}
}
} }
} }

View file

@ -40,6 +40,10 @@
<!-- Feil --> <!-- Feil -->
<string name="error_download_failed">Kunne ikke laste ned tilfluktsromdata. Sjekk internettforbindelsen.</string> <string name="error_download_failed">Kunne ikke laste ned tilfluktsromdata. Sjekk internettforbindelsen.</string>
<string name="error_no_data_offline">Ingen lagrede data tilgjengelig. Koble til internett for å laste ned tilfluktsromdata.</string> <string name="error_no_data_offline">Ingen lagrede data tilgjengelig. Koble til internett for å laste ned tilfluktsromdata.</string>
<string name="error_no_compass">Kompass er ikke tilgjengelig på denne enheten</string>
<string name="update_success">Tilfluktsromdata oppdatert</string> <string name="update_success">Tilfluktsromdata oppdatert</string>
<string name="update_failed">Oppdatering mislyktes — bruker lagrede data</string> <string name="update_failed">Oppdatering mislyktes — bruker lagrede data</string>
<!-- Tilgjengelighet -->
<string name="direction_arrow_description">Retning til tilfluktsrom, %s unna</string>
</resources> </resources>

View file

@ -40,6 +40,10 @@
<!-- Feil --> <!-- Feil -->
<string name="error_download_failed">Kunne ikkje laste ned tilfluktsromdata. Sjekk internettilkoplinga.</string> <string name="error_download_failed">Kunne ikkje laste ned tilfluktsromdata. Sjekk internettilkoplinga.</string>
<string name="error_no_data_offline">Ingen lagra data tilgjengeleg. Kopla til internett for å laste ned tilfluktsromdata.</string> <string name="error_no_data_offline">Ingen lagra data tilgjengeleg. Kopla til internett for å laste ned tilfluktsromdata.</string>
<string name="error_no_compass">Kompass er ikkje tilgjengeleg på denne eininga</string>
<string name="update_success">Tilfluktsromdata oppdatert</string> <string name="update_success">Tilfluktsromdata oppdatert</string>
<string name="update_failed">Oppdatering mislukkast — brukar lagra data</string> <string name="update_failed">Oppdatering mislukkast — brukar lagra data</string>
<!-- Tilgjenge -->
<string name="direction_arrow_description">Retning til tilfluktsrom, %s unna</string>
</resources> </resources>

View file

@ -40,6 +40,10 @@
<!-- Errors --> <!-- Errors -->
<string name="error_download_failed">Could not download shelter data. Check your internet connection.</string> <string name="error_download_failed">Could not download shelter data. Check your internet connection.</string>
<string name="error_no_data_offline">No cached data available. Connect to the internet to download shelter data.</string> <string name="error_no_data_offline">No cached data available. Connect to the internet to download shelter data.</string>
<string name="error_no_compass">Compass not available on this device</string>
<string name="update_success">Shelter data updated</string> <string name="update_success">Shelter data updated</string>
<string name="update_failed">Update failed — using cached data</string> <string name="update_failed">Update failed — using cached data</string>
<!-- Accessibility -->
<string name="direction_arrow_description">Direction to shelter, %s away</string>
</resources> </resources>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<style name="Theme.Tilfluktsrom" parent="Theme.Material3.DayNight.NoActionBar"> <style name="Theme.Tilfluktsrom" parent="Theme.Material3.Dark.NoActionBar">
<item name="colorPrimary">@color/shelter_primary</item> <item name="colorPrimary">@color/shelter_primary</item>
<item name="colorPrimaryDark">@color/shelter_primary_dark</item> <item name="colorPrimaryDark">@color/shelter_primary_dark</item>
<item name="colorAccent">@color/shelter_accent</item> <item name="colorAccent">@color/shelter_accent</item>