diff --git a/ACCESSIBILITY.md b/ACCESSIBILITY.md new file mode 100644 index 0000000..ca78220 --- /dev/null +++ b/ACCESSIBILITY.md @@ -0,0 +1,86 @@ +# 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). Statusen er «delvis» fordi ett punkt er dokumentert som uforholdsmessig byrde (se avsnitt 3.2); egenkontroll har ikke identifisert andre gjenværende avvik på dette nivået per versjon 1.9.0. + +## 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 + +Per versjon 1.9.0 har egenkontroll ikke identifisert gjenværende avvik på dette nivået i Android-appen. Tidligere identifiserte avvik som er utbedret, er dokumentert i avsnitt 7 (Historikk). + +### 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. + +## 7. Historikk + +### Versjon 1.9.0 — 17. april 2026 + +Følgende avvik ble identifisert ved egenkontroll og utbedret i denne versjonen: + +- **WCAG 2.4.7 Synlig fokus.** 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 er synlig ved tastatur- og bryternavigasjon. +- **WCAG 4.1.2 Navn, rolle, verdi.** `DirectionArrowView` rapporterer seg nå 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. +- **WCAG 1.4.3 Minimumskontrast.** Advarselsbanneret for manglende offline-kart hadde tidligere bakgrunnsfarge `#E65100` med hvit tekst (kontrastforhold 3,75 : 1, under AA-kravet på 4,5 : 1). Bakgrunnsfargen er endret til `#BF360C`, som gir kontrast på omkring 5,5 : 1 mot hvit tekst. +- **WCAG 2.3.3 Bevegelse fra interaksjon.** Retningspilen rotert tidligere kontinuerlig med kompasset, uten hensyn til Android-innstillingen «Fjern animasjoner». Når `Settings.Global.ANIMATOR_DURATION_SCALE` er 0, snapper retningspilen nå til nærmeste av åtte 45°-sektorer i stedet for å rotere kontinuerlig. + +--- + +## 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 §7 (Historikk) under a new `Versjon X.Y.Z` subsection, and update §1 *Samsvarsstatus* accordingly. When §3.1 and §3.2 are both empty, §1 becomes "i samsvar". +- [ ] 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. +- [x] Confirm §6 *Tilsynsmyndighet*. Digdir / Uutilsynet is the correct supervisory body for this app (confirmed 2026-04-17); no buyer-specific override expected. +- [ ] 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..51f2552 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "no.naiv.tilfluktsrom" minSdk = 26 targetSdk = 35 - versionCode = 12 - versionName = "1.8.0" + versionCode = 13 + versionName = "1.9.0" // Deep link domain — single source of truth for manifest + Kotlin code val deepLinkDomain = "tilfluktsrom.naiv.no" @@ -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/data/ShelterRepository.kt b/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterRepository.kt index 048a93d..a668bd7 100644 --- a/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterRepository.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterRepository.kt @@ -46,7 +46,7 @@ class ShelterRepository(private val context: Context) { .readTimeout(60, TimeUnit.SECONDS) .addInterceptor(Interceptor { chain -> chain.proceed(chain.request().newBuilder() - .header("User-Agent", "Tilfluktsrom/1.8.0") + .header("User-Agent", "Tilfluktsrom/1.9.0") .build()) }) .build() 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