From e4f44ede97b938c98ed40aaf2fd5c55eeaa45507 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Fri, 17 Apr 2026 19:02:55 +0200 Subject: [PATCH] =?UTF-8?q?Utbedre=20WCAG=202.2=20AA-avvik=20og=20legg=20t?= =?UTF-8?q?il=20tilgjengelighetserkl=C3=A6ring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ACCESSIBILITY.md | 81 +++++++++++ app/build.gradle.kts | 3 + .../java/no/naiv/tilfluktsrom/MainActivity.kt | 17 ++- .../tilfluktsrom/ui/DirectionArrowView.kt | 129 +++++++++++++++++- app/src/main/res/layout/activity_main.xml | 16 ++- app/src/main/res/values-nb/strings.xml | 9 ++ app/src/main/res/values-nn/strings.xml | 9 ++ app/src/main/res/values/colors.xml | 4 +- app/src/main/res/values/strings.xml | 10 ++ app/src/main/res/values/themes.xml | 4 + .../tilfluktsrom/ui/DirectionArrowViewTest.kt | 98 +++++++++++++ 11 files changed, 364 insertions(+), 16 deletions(-) create mode 100644 ACCESSIBILITY.md create mode 100644 app/src/test/java/no/naiv/tilfluktsrom/ui/DirectionArrowViewTest.kt diff --git a/ACCESSIBILITY.md b/ACCESSIBILITY.md new file mode 100644 index 0000000..af19484 --- /dev/null +++ b/ACCESSIBILITY.md @@ -0,0 +1,81 @@ +# Tilgjengelighetserklæring for Tilfluktsrom + +> **English preamble** (not part of the statement): +> This is the accessibility statement for the Tilfluktsrom app (Android + PWA). +> The authoritative version is in Norwegian Bokmål — that is what Uutilsynet and +> Norwegian public-sector buyers will review. If you need an English translation +> for procurement or contributor review, contact the maintainer (section 5). +> A maintainer checklist in English follows at the end of this file and must be +> stripped before publication. + +--- + +## 1. Samsvarsstatus + +Tilfluktsrom er **delvis i samsvar** med kravene i EN 301 549 (basert på WCAG 2.2 nivå AA). De kjente avvikene er listet opp i avsnitt 3. + +## 2. Referansestandard + +Erklæringen bygger på EU-direktiv 2016/2102 (Web Accessibility Directive, WAD) slik det er innført i norsk rett, og bruker EN 301 549 / WCAG 2.2 AA som teknisk referanse. For publikumsveiledning henviser vi til Digitaliseringsdirektoratet (Digdir) / Uutilsynet på https://www.uutilsynet.no/. + +## 3. Ikke-tilgjengelig innhold + +### 3.1 Manglende samsvar med regelverket + +Følgende avvik er identifisert ved egenkontroll. Utbedringer er lagt inn i kildekoden per 17. april 2026 og inngår i første utgave som bygges og publiseres etter denne datoen. Når utgaven er publisert, flyttes hvert punkt til et «Utbedret»-avsnitt med konkret versjonsnummer, og avsnitt 1 *Samsvarsstatus* oppdateres tilsvarende. + +- **WCAG 2.4.7 Synlig fokus.** Android-appen hadde ikke tydelig synlig fokusmarkering på alle egendefinerte visninger (bl.a. FAB-knapper og den egenutviklede retningspilen `DirectionArrowView`). Skjermtastatur- og bryterbrukere kunne derfor miste oversikten over hvor fokus var plassert. *Status per 17. april 2026: utbedret i kildekode. Systemets standard fokusmarkering er slått på for temaet `Theme.Tilfluktsrom` via `android:defaultFocusHighlightEnabled`, og bundne ripple-bakgrunner (`?attr/selectableItemBackground`) er tatt i bruk på ikonknappene slik at fokusringen blir synlig ved tastatur- og bryternavigasjon. Venter på utgivelse.* +- **WCAG 4.1.2 Navn, rolle, verdi.** Retningspilen i kompassvisningen er tegnet på et `Canvas` og eksponerte ikke retnings- og avstandsinformasjon til TalkBack på en måte som ble oppdatert dynamisk. Skjermleserbrukere fikk derfor ikke samme navigasjonsstøtte som seende brukere. *Status per 17. april 2026: utbedret i kildekode. `DirectionArrowView` rapporterer seg som `ImageView` og setter innholdsbeskrivelse på formen «Retning til tilfluktsrom: , unna»; endringer annonseres til TalkBack hver gang retningen krysser en 45°-sektor, med 750 ms-struping mot spam. Retningsbegrepene er oversatt til både bokmål og nynorsk. Venter på utgivelse.* +- **WCAG 1.4.3 Minimumskontrast.** Advarselsbanneret for manglende offline-kart brukte bakgrunnsfarge `#E65100` med hvit tekst. Kontrastforholdet var 3,75 : 1, under AA-kravet på 4,5 : 1. *Status per 17. april 2026: utbedret i kildekode. Bakgrunnsfargen er endret til `#BF360C`, som gir kontrast på omkring 5,5 : 1 mot hvit tekst. Venter på utgivelse.* +- **WCAG 2.3.3 Bevegelse fra interaksjon.** Retningspilen roterte kontinuerlig med kompasset mens brukeren vendte på enheten. Appen tok ikke hensyn til Android-innstillingen «Fjern animasjoner» / redusert bevegelse, og brukere med vestibulære plager hadde ikke mulighet til å slå av den kontinuerlige rotasjonen. *Status per 17. april 2026: utbedret i kildekode. Når `Settings.Global.ANIMATOR_DURATION_SCALE` er 0 (brukeren har slått av animasjoner), snapper retningspilen til nærmeste av åtte 45°-sektorer i stedet for å rotere kontinuerlig. Venter på utgivelse.* + +### 3.2 Uforholdsmessig byrde + +- **Full tastaturnavigasjon i kartet (PWA).** Leaflet-kartet støtter zoom og grunnleggende pan med tastatur, men ikke effektiv tastaturnavigasjon til vilkårlige geografiske punkter. Å bygge et fullt tilgjengelig alternativt kart vil være uforholdsmessig for en privatfinansiert kodebase. Avviket er delvis avbøtet ved at appen alltid viser en tekstbasert liste over nærmeste tilfluktsrom som fungerer fullt ut uten mus eller kart. + +Vurderingen skal dokumenteres nærmere før erklæringen brukes i en offentlig anskaffelsessammenheng; se vedlikeholdsnotatet nederst. + +### 3.3 Innhold utenfor virkeområdet + +Følgende innhold er ikke omfattet av regelverket og vurderes heller ikke som en del av erklæringen: + +- Kartfliser fra OpenStreetMap er tredjepartsinnhold som ikke er under vår kontroll. +- Offentlige tilfluktsromdata fra DSB/Geonorge vises uendret. Eventuelle feil i kildedata (adresse, kapasitet, plassering) kan ikke rettes i appen og må meldes til dataeier. +- Operativsystemkomponenter (bl.a. Android systemdialoger, nettleserens UI for PWA-installasjon) er ikke vårt ansvar. + +## 4. Utarbeiding av erklæringen + +- **Vurderingsmetode:** Egenkontroll. Ingen uavhengig tredjepartsrevisjon er gjennomført. +- **Dato erklæringen ble utarbeidet:** 17. april 2026. +- **Dato for siste gjennomgang:** 17. april 2026. + +## 5. Tilbakemelding og kontaktinformasjon + +Har du oppdaget en tilgjengelighetsfeil som ikke er nevnt her, eller trenger du innholdet i et annet format? + +- **E-post:** olemd@odinprosjekt.no +- **Forventet svartid:** 14 dager. + +## 6. Klageordning + +Er du ikke fornøyd med svaret du får, kan saken meldes til tilsynsorganet: + +- **Tilsynsmyndighet:** Digitaliseringsdirektoratet (Digdir) / Uutilsynet. +- **Nettsted:** https://www.uutilsynet.no/ +- **Klagerett:** Du har rett til å melde manglende universell utforming av digitale tjenester til tilsynet. + +--- + +## Notes for the maintainer (English — strip before publication) + +This statement is honest today but must be kept up to date. Before using it as a deliverable alongside a public-sector licensing agreement or a live public-sector deployment: + +- [ ] Replace `olemd@odinprosjekt.no` in §5 with a dedicated, monitored, public-facing address that the licensee (or you, if self-hosting) commits to reading within the stated response time. +- [ ] After each release that ships an accessibility fix, move the corresponding item from §3.1 to an "Utbedret i versjon X.Y.Z" (resolved) list and update §1 *Samsvarsstatus* accordingly. When §3.1 is empty, §1 becomes "i samsvar". +- [ ] Replace each *Venter på utgivelse* status with a concrete version number as soon as that version ships, so buyers can verify against the changelog. +- [ ] Update §4 *Dato for siste gjennomgang* at every release where user-facing code or content changes, even if nothing in §3 changes. +- [ ] Commission an independent WCAG 2.2 AA audit before the first public-sector deployment. §4 must then be amended from "Egenkontroll" to cite the auditor, their contact, and the audit date. +- [ ] Confirm §6 *Tilsynsmyndighet* matches the actual supervisory body for the buyer's sector. For most Norwegian public-sector bodies it is Digdir/Uutilsynet, but sector-specific services (e.g. under DSB or Sivilforsvaret) may have alternative complaint paths. +- [ ] Re-verify the §3.2 disproportionate-burden claim before publication. WAD requires a documented cost-vs-benefit assessment, not a bare claim. Keep a short internal memo on file. +- [ ] If an English translation is provided for international reviewers, mark it clearly as a courtesy translation and keep the Bokmål version authoritative. +- [ ] Re-verify: is the deployed service actually covered by WAD? Privately-owned apps are exempt; coverage begins once the app is procured by a public-sector body or otherwise delivers a public-sector service. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5992c73..945741e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") } diff --git a/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt b/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt index 5f80847..24a06ca 100644 --- a/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt @@ -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) diff --git a/app/src/main/java/no/naiv/tilfluktsrom/ui/DirectionArrowView.kt b/app/src/main/java/no/naiv/tilfluktsrom/ui/DirectionArrowView.kt index aa50e8f..c81474d 100644 --- a/app/src/main/java/no/naiv/tilfluktsrom/ui/DirectionArrowView.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/ui/DirectionArrowView.kt @@ -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 + } } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 4c8701e..2c43bcf 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -36,11 +36,14 @@ android:textSize="12sp" tools:text="@string/status_ready" /> + @@ -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 @@ + android:layout_height="match_parent" + android:focusable="true" + android:importantForAccessibility="yes" /> + android:layout_height="48dp" + android:importantForAccessibility="yes" /> diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 8f0f2d3..aa0f1f0 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -69,6 +69,15 @@ Upresist kompass - %s Tilfluktsromkart Kompassnavigasjon + Retning til tilfluktsrom: %1$s, %2$s unna + rett frem + fremover til høyre + til høyre + bak til høyre + rett bak + bak til venstre + til venstre + fremover til venstre Sivilforsvarsinformasjon diff --git a/app/src/main/res/values-nn/strings.xml b/app/src/main/res/values-nn/strings.xml index 088398a..f04070e 100644 --- a/app/src/main/res/values-nn/strings.xml +++ b/app/src/main/res/values-nn/strings.xml @@ -69,6 +69,15 @@ Upresis kompass - %s Tilfluktsromkart Kompassnavigasjon + Retning til tilfluktsrom: %1$s, %2$s unna + rett fram + framover til høgre + til høgre + bak til høgre + rett bak + bak til venstre + til venstre + framover til venstre Sivilforsvarsinformasjon diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 59aae1f..9927c95 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -15,7 +15,9 @@ #ECEFF1 #90A4AE - #E65100 + + #BF360C #FFFFFF #FFFFFF diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 34afc98..e6408f7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -69,6 +69,16 @@ Low accuracy - %s Shelter map Compass navigation + + Direction to shelter: %1$s, %2$s away + straight ahead + ahead to the right + to the right + behind to the right + behind you + behind to the left + to the left + ahead to the left Civil defense information diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 098a7f3..c2422dc 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -7,6 +7,10 @@ @color/background @color/status_bar_bg @color/background + + true