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:
parent
e93273bff4
commit
55761718e1
5 changed files with 186 additions and 57 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue