Utbedre WCAG 2.2 AA-avvik og legg til tilgjengelighetserklæring

Fire WCAG-avvik utbedret før appen kan lisensieres til offentlig
sektor (WAD / EN 301 549):

- SC 1.4.3: Advarselsbanner til #BF360C (~5,5:1 mot hvit, var 3,75:1).
- SC 2.4.7: defaultFocusHighlightEnabled i temaet; ikonknapper
  bruker bundet selectableItemBackground.
- SC 4.1.2: DirectionArrowView rapporterer ImageView-rolle og
  annonserer retning + avstand til TalkBack når en 45°-sektor
  krysses, med 750 ms-struping mot spam.
- SC 2.3.3: Snapper til nærmeste 45° når brukeren har slått av
  animasjoner (ANIMATOR_DURATION_SCALE=0).

Nye retningsstrenger i nb/nn/en. JVM-unittester for
sektoraritmetikken sikrer grensetilfeller (negative vinkler,
360°-overgang, vilkårlige vinkler).

ACCESSIBILITY.md dokumenterer tilgjengelighetserklæring etter
WAD artikkel 7, i bokmål med engelsk preambel og
vedlikeholdsjekkliste.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-04-17 19:02:55 +02:00
commit e4f44ede97
11 changed files with 364 additions and 16 deletions

View file

@ -109,4 +109,7 @@ dependencies {
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
// JVM unit tests
testImplementation("junit:junit:4.13.2")
}

View file

@ -124,6 +124,11 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// Only the full-screen compass arrow speaks direction changes to
// TalkBack; the mini arrow in the bottom sheet would otherwise
// double-announce every turn of the device.
binding.miniArrow.announceDirectionChanges = false
repository = ShelterRepository(this)
locationProvider = LocationProvider(this)
mapCacheManager = MapCacheManager(this)
@ -516,22 +521,20 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
R.string.shelter_capacity, selected.shelter.plasser
) + " - " + distanceText
// Update direction arrows with accessibility descriptions
// Update direction arrows. The view derives its own accessibility
// description from distanceText + the angle we feed into
// setDirection(), so we don't set contentDescription manually.
val bearing = selected.bearingDegrees.toFloat()
val arrowAngle = bearing - deviceHeading
binding.miniArrow.setDistanceText(distanceText)
binding.miniArrow.setDirection(arrowAngle)
binding.miniArrow.contentDescription = getString(
R.string.direction_arrow_description, distanceText
)
// Update compass view (large arrow gets a north indicator)
binding.compassDistanceText.text = distanceText
binding.compassAddressText.text = selected.shelter.adresse
binding.directionArrow.setDistanceText(distanceText)
binding.directionArrow.setDirection(arrowAngle)
binding.directionArrow.setNorthAngle(-deviceHeading)
binding.directionArrow.contentDescription = getString(
R.string.direction_arrow_description, distanceText
)
// Emphasize the selected marker on the map
highlightSelectedMarker(selected.shelter.lokalId)

View file

@ -5,8 +5,11 @@ import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.os.SystemClock
import android.provider.Settings
import android.util.AttributeSet
import android.view.View
import android.view.accessibility.AccessibilityNodeInfo
import no.naiv.tilfluktsrom.R
/**
@ -20,6 +23,19 @@ import no.naiv.tilfluktsrom.R
*
* Optionally draws a discrete north indicator on the perimeter so users
* can validate compass calibration against a known direction.
*
* Accessibility:
* - Exposes direction ("straight ahead" / "to the right" / ...) and
* distance to TalkBack via contentDescription, updated on every call
* to [setDirection] using the distance set via [setDistanceText].
* - Announces changes when the direction crosses a 45° sector boundary,
* throttled so rapid device rotation doesn't spam the screen reader.
*
* Reduced motion (WCAG 2.3.3):
* - When the user has set Android's animator duration scale to 0
* ("Remove animations"), the arrow snaps to the nearest 45° sector
* instead of rotating smoothly, removing the continuous motion that
* can trigger vestibular symptoms.
*/
class DirectionArrowView @JvmOverloads constructor(
context: Context,
@ -30,6 +46,26 @@ class DirectionArrowView @JvmOverloads constructor(
private var rotationAngle = 0f
private var northAngle = Float.NaN
/** Last distance text set by the owner (e.g. "1.2 km"). */
private var distanceText: String = ""
/** Controls whether this instance announces direction changes to
* TalkBack. The mini arrow in the bottom sheet sets this to false
* so the full-screen compass arrow is the only one that speaks. */
var announceDirectionChanges: Boolean = true
private var lastAnnouncedSector: Int = -1
private var lastAnnounceTimeMs: Long = 0L
/** True when the user has disabled OS animations. Read once; users
* rarely change this setting while the view is alive. */
private val reduceMotion: Boolean =
Settings.Global.getFloat(
context.contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE,
1f
) == 0f
private val arrowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = context.getColor(R.color.shelter_primary)
style = Paint.Style.FILL
@ -54,13 +90,33 @@ class DirectionArrowView @JvmOverloads constructor(
private val arrowPath = Path()
private val northPath = Path()
/**
* Set the distance text used in the accessibility description
* (e.g. "1.2 km"). Should be called whenever the selected shelter
* or user location changes. Does not trigger a redraw on its own;
* the next [setDirection] call picks up the new text.
*/
fun setDistanceText(text: String) {
distanceText = text
}
/**
* Set the rotation angle in degrees.
* 0 = pointing up (north/forward), positive = clockwise.
* 0 = pointing up (toward the shelter if facing it), positive = clockwise.
*
* Also refreshes the accessibility description and, if the sector
* changed, announces the new direction to TalkBack.
*/
fun setDirection(degrees: Float) {
rotationAngle = degrees
invalidate()
// Visual: snap to 8 cardinal sectors when reduce-motion is on so
// the arrow doesn't rotate continuously with device movement.
val displayAngle = if (reduceMotion) snapToSector(degrees) else degrees
if (displayAngle != rotationAngle) {
rotationAngle = displayAngle
invalidate()
}
updateAccessibility(degrees)
}
/**
@ -132,4 +188,71 @@ class DirectionArrowView @JvmOverloads constructor(
canvas.restore()
}
override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) {
super.onInitializeAccessibilityNodeInfo(info)
// Present as an ImageView so screen readers treat the description
// as the full semantic content, not a container label.
info.className = "android.widget.ImageView"
}
/**
* Update [contentDescription] from the latest bearing and distance,
* and announce the change if the user has crossed a 45° sector
* boundary and enough time has passed since the last announcement.
*/
private fun updateAccessibility(angleDegrees: Float) {
val sector = sectorIndex(angleDegrees)
val directionText = context.getString(sectorStringRes(sector))
val description = context.getString(
R.string.a11y_direction_with_distance,
directionText,
distanceText
)
contentDescription = description
if (!announceDirectionChanges) return
val now = SystemClock.uptimeMillis()
if (sector != lastAnnouncedSector &&
now - lastAnnounceTimeMs >= ANNOUNCE_INTERVAL_MS
) {
lastAnnouncedSector = sector
lastAnnounceTimeMs = now
announceForAccessibility(description)
}
}
private fun sectorStringRes(sector: Int): Int = when (sector) {
0 -> R.string.a11y_dir_forward
1 -> R.string.a11y_dir_forward_right
2 -> R.string.a11y_dir_right
3 -> R.string.a11y_dir_back_right
4 -> R.string.a11y_dir_back
5 -> R.string.a11y_dir_back_left
6 -> R.string.a11y_dir_left
7 -> R.string.a11y_dir_forward_left
else -> R.string.a11y_dir_forward
}
companion object {
/** Minimum gap between TalkBack announcements. Prevents a rapidly
* turning user from flooding the screen reader queue. */
private const val ANNOUNCE_INTERVAL_MS = 750L
/** Map a raw angle (any float) to one of 8 sectors, 0..7 starting
* at "forward" (0°) and going clockwise in 45° steps. Pure function;
* exposed as `internal` so it can be unit-tested on the JVM without
* the Android framework. */
internal fun sectorIndex(angleDegrees: Float): Int {
var a = angleDegrees % 360f
if (a < 0f) a += 360f
return (((a + 22.5f) / 45f).toInt()) % 8
}
/** Snap to the centre of the sector the current angle falls in.
* Returns a value in {0, 45, 90, 135, 180, 225, 270, 315}. */
internal fun snapToSector(angleDegrees: Float): Float =
sectorIndex(angleDegrees) * 45f
}
}

View file

@ -36,11 +36,14 @@
android:textSize="12sp"
tools:text="@string/status_ready" />
<!-- Use bounded selectableItemBackground (not borderless) so the
focus ring is clearly visible when navigating by keyboard or
switch control — required for WCAG 2.4.7 Focus Visible. -->
<ImageButton
android:id="@+id/infoButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:background="?attr/selectableItemBackground"
android:contentDescription="@string/action_civil_defense_info"
android:src="@drawable/ic_info"
app:tint="@color/status_text" />
@ -49,7 +52,7 @@
android:id="@+id/refreshButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:background="?attr/selectableItemBackground"
android:contentDescription="@string/action_refresh"
android:src="@drawable/ic_refresh"
app:tint="@color/status_text" />
@ -90,7 +93,9 @@
<no.naiv.tilfluktsrom.ui.DirectionArrowView
android:id="@+id/directionArrow"
android:layout_width="match_parent"
android:layout_height="match_parent" />
android:layout_height="match_parent"
android:focusable="true"
android:importantForAccessibility="yes" />
<TextView
android:id="@+id/compassDistanceText"
@ -197,7 +202,8 @@
<no.naiv.tilfluktsrom.ui.DirectionArrowView
android:id="@+id/miniArrow"
android:layout_width="48dp"
android:layout_height="48dp" />
android:layout_height="48dp"
android:importantForAccessibility="yes" />
<LinearLayout
android:layout_width="0dp"
@ -229,7 +235,7 @@
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="8dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:background="?attr/selectableItemBackground"
android:contentDescription="@string/action_share"
android:src="@drawable/ic_share"
app:tint="@color/text_secondary" />

View file

@ -69,6 +69,15 @@
<string name="compass_accuracy_warning">Upresist kompass - %s</string>
<string name="a11y_map">Tilfluktsromkart</string>
<string name="a11y_compass">Kompassnavigasjon</string>
<string name="a11y_direction_with_distance">Retning til tilfluktsrom: %1$s, %2$s unna</string>
<string name="a11y_dir_forward">rett frem</string>
<string name="a11y_dir_forward_right">fremover til høyre</string>
<string name="a11y_dir_right">til høyre</string>
<string name="a11y_dir_back_right">bak til høyre</string>
<string name="a11y_dir_back">rett bak</string>
<string name="a11y_dir_back_left">bak til venstre</string>
<string name="a11y_dir_left">til venstre</string>
<string name="a11y_dir_forward_left">fremover til venstre</string>
<!-- Sivilforsvar -->
<string name="action_civil_defense_info">Sivilforsvarsinformasjon</string>

View file

@ -69,6 +69,15 @@
<string name="compass_accuracy_warning">Upresis kompass - %s</string>
<string name="a11y_map">Tilfluktsromkart</string>
<string name="a11y_compass">Kompassnavigasjon</string>
<string name="a11y_direction_with_distance">Retning til tilfluktsrom: %1$s, %2$s unna</string>
<string name="a11y_dir_forward">rett fram</string>
<string name="a11y_dir_forward_right">framover til høgre</string>
<string name="a11y_dir_right">til høgre</string>
<string name="a11y_dir_back_right">bak til høgre</string>
<string name="a11y_dir_back">rett bak</string>
<string name="a11y_dir_back_left">bak til venstre</string>
<string name="a11y_dir_left">til venstre</string>
<string name="a11y_dir_forward_left">framover til venstre</string>
<!-- Sivilforsvar -->
<string name="action_civil_defense_info">Sivilforsvarsinformasjon</string>

View file

@ -15,7 +15,9 @@
<color name="text_primary">#ECEFF1</color>
<color name="text_secondary">#90A4AE</color>
<color name="warning_bg">#E65100</color>
<!-- Warning banner: darker orange so white text meets WCAG 2.2 AA (SC 1.4.3).
#BF360C gives ~5.5:1 vs white; previous #E65100 was only 3.75:1. -->
<color name="warning_bg">#BF360C</color>
<color name="warning_text">#FFFFFF</color>
<color name="white">#FFFFFF</color>

View file

@ -69,6 +69,16 @@
<string name="compass_accuracy_warning">Low accuracy - %s</string>
<string name="a11y_map">Shelter map</string>
<string name="a11y_compass">Compass navigation</string>
<!-- %1$s is one of the a11y_dir_* strings, %2$s is the distance. -->
<string name="a11y_direction_with_distance">Direction to shelter: %1$s, %2$s away</string>
<string name="a11y_dir_forward">straight ahead</string>
<string name="a11y_dir_forward_right">ahead to the right</string>
<string name="a11y_dir_right">to the right</string>
<string name="a11y_dir_back_right">behind to the right</string>
<string name="a11y_dir_back">behind you</string>
<string name="a11y_dir_back_left">behind to the left</string>
<string name="a11y_dir_left">to the left</string>
<string name="a11y_dir_forward_left">ahead to the left</string>
<!-- Civil defense info -->
<string name="action_civil_defense_info">Civil defense information</string>

View file

@ -7,6 +7,10 @@
<item name="android:windowBackground">@color/background</item>
<item name="android:statusBarColor">@color/status_bar_bg</item>
<item name="android:navigationBarColor">@color/background</item>
<!-- WCAG 2.4.7 Focus Visible: ensure the framework draws a default
focus highlight on any focusable view that doesn't supply one of
its own (e.g. our custom DirectionArrowView). -->
<item name="android:defaultFocusHighlightEnabled">true</item>
</style>
<style name="Theme.Tilfluktsrom.Dialog" parent="Theme.Material3.Dark.Dialog">

View file

@ -0,0 +1,98 @@
package no.naiv.tilfluktsrom.ui
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Pure-JVM tests for [DirectionArrowView.sectorIndex] and
* [DirectionArrowView.snapToSector].
*
* These two functions are what the reduce-motion (WCAG 2.3.3) remediation
* relies on: when animations are disabled, the arrow snaps to one of eight
* 45° sectors instead of rotating continuously. The sector index also drives
* the TalkBack accessibility description (WCAG 4.1.2), so any off-by-one in
* this arithmetic is a silent a11y regression. Worth locking down with tests
* that can run in milliseconds without booting an emulator.
*/
class DirectionArrowViewTest {
@Test
fun sectorIndex_wholeCardinals_returnExpectedSector() {
assertEquals(0, DirectionArrowView.sectorIndex(0f)) // forward
assertEquals(1, DirectionArrowView.sectorIndex(45f)) // forward-right
assertEquals(2, DirectionArrowView.sectorIndex(90f)) // right
assertEquals(3, DirectionArrowView.sectorIndex(135f)) // back-right
assertEquals(4, DirectionArrowView.sectorIndex(180f)) // back
assertEquals(5, DirectionArrowView.sectorIndex(225f)) // back-left
assertEquals(6, DirectionArrowView.sectorIndex(270f)) // left
assertEquals(7, DirectionArrowView.sectorIndex(315f)) // forward-left
}
@Test
fun sectorIndex_boundariesSnapToNextSector() {
// A sector covers [center - 22.5°, center + 22.5°). The boundary
// value itself should belong to the following sector.
assertEquals(0, DirectionArrowView.sectorIndex(22.4f))
assertEquals(1, DirectionArrowView.sectorIndex(22.5f))
assertEquals(1, DirectionArrowView.sectorIndex(44.9f))
assertEquals(1, DirectionArrowView.sectorIndex(67.4f))
assertEquals(2, DirectionArrowView.sectorIndex(67.5f))
}
@Test
fun sectorIndex_wrapsAroundAt360() {
// Anything within 22.5° of north should land back in sector 0.
assertEquals(7, DirectionArrowView.sectorIndex(337.4f))
assertEquals(0, DirectionArrowView.sectorIndex(337.5f))
assertEquals(0, DirectionArrowView.sectorIndex(359.99f))
assertEquals(0, DirectionArrowView.sectorIndex(360f))
assertEquals(0, DirectionArrowView.sectorIndex(720f))
assertEquals(2, DirectionArrowView.sectorIndex(450f)) // 450 % 360 = 90
}
@Test
fun sectorIndex_handlesNegativeAngles() {
// Sensors can produce angles slightly below zero due to smoothing;
// subtracting deviceHeading from shelterBearing routinely yields
// negative values. The function must normalise rather than throw.
assertEquals(7, DirectionArrowView.sectorIndex(-45f))
assertEquals(4, DirectionArrowView.sectorIndex(-180f))
assertEquals(0, DirectionArrowView.sectorIndex(-10f))
assertEquals(6, DirectionArrowView.sectorIndex(-90f))
assertEquals(0, DirectionArrowView.sectorIndex(-360f))
}
@Test
fun snapToSector_alwaysReturnsMultipleOf45InZeroTo315() {
// Sweep a dense range of inputs; every output must be a whole 45°
// step in [0, 315]. This is the invariant that prevents vestibular
// motion triggers when reduce-motion is enabled.
var angle = -720f
while (angle <= 720f) {
val snapped = DirectionArrowView.snapToSector(angle)
val remainder = snapped % 45f
assertEquals(
"snap($angle) = $snapped is not a multiple of 45°",
0f, remainder, 0.0001f
)
assertEquals(
"snap($angle) = $snapped is outside [0, 315]",
true, snapped in 0f..315f
)
angle += 0.37f // irrational-ish step hits boundaries from both sides
}
}
@Test
fun snapToSector_knownPoints() {
assertEquals(0f, DirectionArrowView.snapToSector(0f), 0.0001f)
assertEquals(0f, DirectionArrowView.snapToSector(22.4f), 0.0001f)
assertEquals(45f, DirectionArrowView.snapToSector(22.5f), 0.0001f)
assertEquals(90f, DirectionArrowView.snapToSector(90f), 0.0001f)
assertEquals(180f, DirectionArrowView.snapToSector(180f), 0.0001f)
// -45° normalises to 315° (sector 7), which snaps to 315°.
assertEquals(315f, DirectionArrowView.snapToSector(-45f), 0.0001f)
// Just past full rotation wraps to forward.
assertEquals(0f, DirectionArrowView.snapToSector(359.9f), 0.0001f)
}
}