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.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.hardware.Sensor
import android.hardware.SensorEvent
@ -10,17 +11,22 @@ import android.hardware.SensorManager
import android.location.Location
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import android.util.Log
import android.view.View
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import no.naiv.tilfluktsrom.data.MapCacheManager
@ -50,7 +56,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
private lateinit var repository: ShelterRepository
private lateinit var locationProvider: LocationProvider
private lateinit var mapCacheManager: MapCacheManager
private lateinit var sensorManager: SensorManager
private var sensorManager: SensorManager? = null
private lateinit var shelterAdapter: ShelterListAdapter
private var myLocationOverlay: MyLocationNewOverlay? = null
@ -59,12 +65,14 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
private var nearestShelters: List<ShelterWithDistance> = emptyList()
private var deviceHeading = 0f
private var isCompassMode = false
private var locationJob: Job? = null
private var cachingJob: Job? = null
// Map from shelter lokalId to its map marker, for icon swapping on selection
private var shelterMarkerMap: MutableMap<String, Marker> = mutableMapOf()
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
private var selectedShelter: ShelterWithDistance? = null
// When true, location updates won't auto-select the nearest shelter
@ -81,7 +89,26 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
if (fineGranted || coarseGranted) {
startLocationUpdates()
} else {
Toast.makeText(this, R.string.permission_denied, Toast.LENGTH_LONG).show()
// 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 {
Toast.makeText(this, R.string.permission_denied, Toast.LENGTH_LONG).show()
}
}
}
@ -93,7 +120,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
repository = ShelterRepository(this)
locationProvider = LocationProvider(this)
mapCacheManager = MapCacheManager(this)
sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager
sensorManager = getSystemService(Context.SENSOR_SERVICE) as? SensorManager
setupMap()
setupShelterList()
@ -122,9 +149,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
// Add user location overlay
myLocationOverlay = MyLocationNewOverlay(
GpsMyLocationProvider(this@MainActivity), this
).apply {
enableMyLocation()
}
)
overlays.add(myLocationOverlay)
}
}
@ -137,7 +162,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
}
binding.shelterList.apply {
layoutManager = LinearLayoutManager(this@MainActivity)
layoutManager = androidx.recyclerview.widget.LinearLayoutManager(this@MainActivity)
adapter = shelterAdapter
}
}
@ -199,11 +224,18 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
// Observe shelter data reactively
launch {
repository.getAllShelters().collectLatest { shelters ->
allShelters = shelters
binding.statusText.text = getString(R.string.status_shelters_loaded, shelters.size)
updateShelterMarkers()
currentLocation?.let { updateNearestShelters(it) }
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)
}
}
@ -216,6 +248,8 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
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()
}
}
}
@ -225,35 +259,66 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
private fun requestLocationPermission() {
if (locationProvider.hasLocationPermission()) {
startLocationUpdates()
} else {
locationPermissionRequest.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
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 {
launchPermissionRequest()
}
}
private fun launchPermissionRequest() {
locationPermissionRequest.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
)
}
private fun startLocationUpdates() {
locationJob?.cancel()
locationJob = lifecycleScope.launch {
locationProvider.locationUpdates().collectLatest { location ->
currentLocation = location
updateNearestShelters(location)
// Use repeatOnLifecycle(STARTED) so GPS stops when Activity is paused
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
try {
locationProvider.locationUpdates().collectLatest { location ->
currentLocation = location
updateNearestShelters(location)
// Center map on first location fix
if (nearestShelters.isEmpty()) {
binding.mapView.controller.animateTo(
GeoPoint(location.latitude, location.longitude)
)
}
// Center map on first location fix
if (nearestShelters.isEmpty()) {
binding.mapView.controller.animateTo(
GeoPoint(location.latitude, location.longitude)
)
}
// Cache map tiles on first launch
if (!mapCacheManager.hasCacheForLocation(location.latitude, location.longitude)) {
if (isNetworkAvailable()) {
cacheMapTiles(location.latitude, location.longitude)
// Cache map tiles on first launch
if (!mapCacheManager.hasCacheForLocation(location.latitude, location.longitude)) {
if (isNetworkAvailable()) {
cacheMapTiles(location.latitude, location.longitude)
}
}
}
} 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
) + " - " + distanceText
// Update mini arrow in bottom sheet
// Update direction arrows with accessibility descriptions
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
binding.compassDistanceText.text = distanceText
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
highlightSelectedMarker(selected.shelter.lokalId)
@ -465,14 +537,18 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
binding.loadingButtonRow.visibility = View.GONE
cachingJob = lifecycleScope.launch {
mapCacheManager.cacheMapArea(
val success = mapCacheManager.cacheMapArea(
binding.mapView, latitude, longitude
) { progress ->
binding.loadingText.text = getString(R.string.loading_map) +
" (${(progress * 100).toInt()}%)"
}
hideLoading()
binding.statusText.text = getString(R.string.status_shelters_loaded, allShelters.size)
if (success) {
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 {
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 caps = cm.getNetworkCapabilities(network) ?: return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
@ -520,18 +597,28 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
override fun onResume() {
super.onResume()
binding.mapView.onResume()
myLocationOverlay?.enableMyLocation()
// Register for rotation vector (best compass source)
val rotationSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
val sm = sensorManager ?: return
// Try rotation vector first (best compass source)
val rotationSensor = sm.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
if (rotationSensor != null) {
sensorManager.registerListener(this, rotationSensor, SensorManager.SENSOR_DELAY_UI)
sm.registerListener(this, rotationSensor, SensorManager.SENSOR_DELAY_UI)
hasCompassSensor = true
} else {
// Fallback to accelerometer + magnetometer
sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)?.let {
sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_UI)
}
sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)?.let {
sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_UI)
val accel = sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
val mag = sm.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)
if (accel != null && mag != null) {
sm.registerListener(this, accel, 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() {
super.onPause()
binding.mapView.onPause()
sensorManager.unregisterListener(this)
myLocationOverlay?.disableMyLocation()
sensorManager?.unregisterListener(this)
}
private val gravity = FloatArray(3)
@ -557,16 +645,23 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
updateDirectionArrows()
}
Sensor.TYPE_ACCELEROMETER -> {
System.arraycopy(event.values, 0, gravity, 0, 3)
lowPassFilter(event.values, gravity)
updateFromAccelMag()
}
Sensor.TYPE_MAGNETIC_FIELD -> {
System.arraycopy(event.values, 0, geomagnetic, 0, 3)
lowPassFilter(event.values, geomagnetic)
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() {
val r = FloatArray(9)
if (SensorManager.getRotationMatrix(r, null, gravity, geomagnetic)) {
@ -587,6 +682,28 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
}
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 -->
<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_compass">Kompass er ikke tilgjengelig på denne enheten</string>
<string name="update_success">Tilfluktsromdata oppdatert</string>
<string name="update_failed">Oppdatering mislyktes — bruker lagrede data</string>
<!-- Tilgjengelighet -->
<string name="direction_arrow_description">Retning til tilfluktsrom, %s unna</string>
</resources>

View file

@ -40,6 +40,10 @@
<!-- Feil -->
<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_compass">Kompass er ikkje tilgjengeleg på denne eininga</string>
<string name="update_success">Tilfluktsromdata oppdatert</string>
<string name="update_failed">Oppdatering mislukkast — brukar lagra data</string>
<!-- Tilgjenge -->
<string name="direction_arrow_description">Retning til tilfluktsrom, %s unna</string>
</resources>

View file

@ -40,6 +40,10 @@
<!-- Errors -->
<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_compass">Compass not available on this device</string>
<string name="update_success">Shelter data updated</string>
<string name="update_failed">Update failed — using cached data</string>
<!-- Accessibility -->
<string name="direction_arrow_description">Direction to shelter, %s away</string>
</resources>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<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="colorPrimaryDark">@color/shelter_primary_dark</item>
<item name="colorAccent">@color/shelter_accent</item>