From ae249d5d47487a92cb0409b747a3df25c7c96a54 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 23 Mar 2026 16:04:49 +0100 Subject: [PATCH] Dokumenter arkitektur for Android-app og PWA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beskriver designprinsipp, datapipeline, plattformspesifikke implementasjonsdetaljar, delte algoritmar (Haversine, UTM-konvertering), fråkobla-først-strategi og sikkerheit/personvern. Co-Authored-By: Claude Opus 4.6 (1M context) --- ARCHITECTURE.md | 448 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 448 insertions(+) create mode 100644 ARCHITECTURE.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..073fa52 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,448 @@ +# Architecture + +This document describes the architecture of Tilfluktsrom, a Norwegian emergency shelter finder available as an Android app and a Progressive Web App (PWA). Both platforms share the same design philosophy — offline-first, no hard external dependencies — but are implemented independently. + +## Table of Contents + +- [Design Principles](#design-principles) +- [Data Pipeline](#data-pipeline) +- [Android App](#android-app) + - [Package Structure](#package-structure) + - [Data Layer](#data-layer) + - [Location & Navigation](#location--navigation) + - [Compass System](#compass-system) + - [Map & Tile Caching](#map--tile-caching) + - [Build Variants](#build-variants) + - [Home Screen Widget](#home-screen-widget) + - [Deep Linking](#deep-linking) +- [Progressive Web App](#progressive-web-app) + - [Module Structure](#module-structure) + - [Build System](#build-system) + - [Data Layer (PWA)](#data-layer-pwa) + - [Location & Compass (PWA)](#location--compass-pwa) + - [Map & Offline Tiles (PWA)](#map--offline-tiles-pwa) + - [Service Worker](#service-worker) + - [UI Components](#ui-components) +- [Shared Algorithms](#shared-algorithms) + - [UTM33N → WGS84 Conversion](#utm33n--wgs84-conversion) + - [Haversine Distance & Bearing](#haversine-distance--bearing) +- [Offline Capability Summary](#offline-capability-summary) +- [Security & Privacy](#security--privacy) +- [Internationalization](#internationalization) + +--- + +## Design Principles + +### Offline-First +This is an emergency app. Core functionality — finding the nearest shelter, compass navigation, distance display — must work without internet after initial setup. Network is used only for initial data download, periodic refresh, and map tile caching. + +### De-Google Compatibility (Android) +The Android app runs on devices without Google Play Services (LineageOS, GrapheneOS, /e/OS). Every Google-specific API has an AOSP fallback. Play Services improve accuracy and battery life when available, but are never required. + +### Minimal Dependencies +Both platforms use few, well-chosen libraries. No heavy frameworks, no external CDNs at runtime. The PWA bundles everything locally; the Android app uses only OSMDroid, Room, OkHttp, and WorkManager. + +### Data Sovereignty +Shelter data comes directly from Geonorge (the Norwegian mapping authority). No intermediate servers. The app fetches, converts, and caches the data locally. + +--- + +## Data Pipeline + +Both platforms consume the same upstream data source: + +``` +Geonorge ZIP (EPSG:25833 UTM33N) + ↓ Download (~320KB) + ↓ Extract GeoJSON from ZIP + ↓ Parse features + ↓ Convert UTM33N → WGS84 (Karney method) + ↓ Validate (Norway bounding box, required fields) + ↓ Store locally +``` + +**Source URL:** `https://nedlasting.geonorge.no/geonorge/Samfunnssikkerhet/TilfluktsromOffentlige/GeoJSON/Samfunnssikkerhet_0000_Norge_25833_TilfluktsromOffentlige_GeoJSON.zip` + +**Fields per shelter:** + +| Field | Description | +|------------|--------------------------------------| +| `lokalId` | Unique identifier (UUID) | +| `romnr` | Shelter room number | +| `plasser` | Capacity (number of people) | +| `adresse` | Street address | +| `latitude` | WGS84 latitude (converted from UTM) | +| `longitude`| WGS84 longitude (converted from UTM) | + +**When conversion happens:** +- **Android:** At runtime during data download (`ShelterGeoJsonParser`) +- **PWA:** At build time (`scripts/fetch-shelters.ts`), output is pre-converted `shelters.json` + +--- + +## Android App + +**Language:** Kotlin · **Min SDK:** 26 (Android 8.0) · **Target SDK:** 35 +**Build:** Gradle 8.7, AGP 8.5.2, KSP for Room annotation processing + +### Package Structure + +``` +no.naiv.tilfluktsrom/ +├── TilfluktsromApp.kt # Application class (OSMDroid config) +├── MainActivity.kt # Central UI controller (~870 lines) +├── data/ +│ ├── Shelter.kt # Room entity +│ ├── ShelterDatabase.kt # Room database singleton +│ ├── ShelterDao.kt # Data access object (Flow queries) +│ ├── ShelterRepository.kt # Repository: bundled seed + network refresh +│ ├── ShelterGeoJsonParser.kt # GeoJSON ZIP → Shelter list +│ └── MapCacheManager.kt # Offline map tile pre-caching +├── location/ +│ ├── LocationProvider.kt # GPS provider (flavor-specific) +│ └── ShelterFinder.kt # Nearest N shelters by Haversine +├── ui/ +│ ├── DirectionArrowView.kt # Custom compass arrow View +│ ├── ShelterListAdapter.kt # RecyclerView adapter for shelter list +│ ├── CivilDefenseInfoDialog.kt # Emergency instructions +│ └── AboutDialog.kt # Privacy and copyright +├── util/ +│ ├── CoordinateConverter.kt # UTM33N → WGS84 (Karney method) +│ └── DistanceUtils.kt # Haversine distance and bearing +└── widget/ + ├── ShelterWidgetProvider.kt # Home screen widget (flavor-specific) + └── WidgetUpdateWorker.kt # WorkManager periodic update +``` + +Files under `location/` and `widget/` have separate implementations per build variant: +- `app/src/standard/java/` — Google Play Services variant +- `app/src/fdroid/java/` — AOSP-only variant + +### Data Layer + +**Storage:** Room (SQLite) with a single `shelters` table. + +**Loading strategy (three-layer fallback):** + +1. **Bundled asset** (`assets/shelters.json`): Pre-converted WGS84 data, loaded on first launch via `ShelterRepository.seedFromAsset()`. Marked as stale (timestamp=0) so a network refresh is attempted when possible. + +2. **Room database**: ~556 shelters cached locally. Reactive updates via Kotlin Flow. Atomic refresh: `deleteAll()` + `insertAll()` in a single transaction. + +3. **Network refresh**: Downloads the Geonorge ZIP via OkHttp, parses with `ShelterGeoJsonParser`. Staleness threshold: 7 days. Runs in the background; failure does not block the UI. + +**GeoJSON parsing pipeline:** +``` +OkHttp response stream + → ZipInputStream (find .geojson entry, 10MB size limit) + → JSON parsing (features array) + → Per feature: + → Extract UTM33N coordinates + → CoordinateConverter.utm33nToWgs84() + → Validate: lokalId not blank, plasser ≥ 0, within Norway bounds + → Create Shelter entity (skip malformed features with warning) +``` + +### Location & Navigation + +**LocationProvider** abstracts GPS access with two flavor implementations: + +| Flavor | Primary | Fallback | +|-----------|----------------------------------|-------------------| +| standard | FusedLocationProviderClient | LocationManager | +| fdroid | LocationManager | — | + +Both emit location updates via Kotlin Flow. Update interval: 5 seconds, fastest 2 seconds. + +**ShelterFinder** takes the user's position and all shelters, computes Haversine distance and initial bearing for each, sorts by distance, and returns the N nearest (default: 3) as `ShelterWithDistance` objects. + +### Compass System + +**Sensor priority:** +1. Rotation Vector sensor (`TYPE_ROTATION_VECTOR`) — most accurate, single sensor +2. Accelerometer + Magnetometer — low-pass filtered (α=0.25) for smoothing +3. No compass available — error message shown + +**Direction calculation:** +``` +arrowAngle = shelterBearing − deviceHeading +``` + +**DirectionArrowView** is a custom View that draws: +- A large arrow rotated by `arrowAngle`, pointing toward the shelter +- An optional north indicator on the perimeter for compass calibration + +### Map & Tile Caching + +**Map library:** OSMDroid (OpenStreetMap, no Google dependency). + +**Tile caching:** OSMDroid's built-in `SqlTileWriter` passively caches every tile loaded. `MapCacheManager` supplements this with active pre-caching: + +- Pans the MapView across a 3×3 grid at zoom levels 10, 12, 14, 16 +- 300ms delay between pans (respects OSM tile usage policy) +- Covers ~15km radius around the user +- Progress reported via callback for UI display + +Tile cache stored in app-specific internal storage (`osmdroidBasePath`). + +### Build Variants + +``` +productFlavors { + standard { } // Google Play Services + AOSP fallback + fdroid { } // AOSP only +} +``` + +The `standard` flavor adds `com.google.android.gms:play-services-location`. Runtime detection via `GoogleApiAvailability` determines which code path runs. + +Both flavors produce identical user experiences — `standard` achieves faster GPS fixes and better battery efficiency when Play Services are present. + +### Home Screen Widget + +**ShelterWidgetProvider** displays the nearest shelter's address, capacity, and distance. Updated by: + +1. **MainActivity** — sends latest location on each GPS update +2. **WorkManager** — `WidgetUpdateWorker` runs every 15 minutes, requests a fresh location fix +3. **Manual** — user taps refresh button on the widget + +**Location resolution (priority order):** +1. Location from intent (WorkManager or MainActivity) +2. FusedLocationProviderClient cache (standard) +3. Active GPS request (10s timeout) +4. LocationManager cache +5. SharedPreferences saved location (max 24h old) + +### Deep Linking + +URI scheme: `tilfluktsrom://shelter/{lokalId}` + +Used in share messages so recipients can open the app directly to a specific shelter. + +--- + +## Progressive Web App + +**Stack:** TypeScript, Vite 5, Leaflet, idb (IndexedDB wrapper), vite-plugin-pwa +**Package manager:** bun + +### Module Structure + +``` +pwa/ +├── index.html # SPA shell, CSP headers, semantic layout +├── vite.config.ts # Build config, PWA plugin, tile caching rules +├── manifest.webmanifest # PWA metadata and icons +├── scripts/ +│ └── fetch-shelters.ts # Build-time: download + convert shelter data +├── public/ +│ └── data/shelters.json # Pre-processed shelter data (build artifact) +└── src/ + ├── main.ts # Entry point, SW registration, locale init + ├── app.ts # Main controller (~400 lines) + ├── types.ts # Shelter, ShelterWithDistance, LatLon interfaces + ├── data/ + │ ├── shelter-repository.ts # Fetch + IndexedDB storage + │ └── shelter-db.ts # IndexedDB wrapper (idb library) + ├── location/ + │ ├── location-provider.ts # navigator.geolocation wrapper + │ ├── compass-provider.ts # DeviceOrientationEvent (iOS/Android) + │ └── shelter-finder.ts # Haversine nearest-N calculation + ├── ui/ + │ ├── map-view.ts # Leaflet map, custom SVG markers + │ ├── compass-view.ts # Canvas-based direction arrow + │ ├── shelter-list.ts # Bottom sheet shelter list + │ ├── loading-overlay.ts # Modal spinner / cache prompt + │ ├── about-dialog.ts # Privacy, data info, cache clear + │ ├── civil-defense-dialog.ts # DSB 5-step emergency guide + │ └── status-bar.ts # Status text and refresh button + ├── cache/ + │ └── map-cache-manager.ts # Tile pre-caching via programmatic panning + ├── i18n/ + │ ├── i18n.ts # Locale detection, string substitution + │ ├── en.ts # English strings + │ ├── nb.ts # Bokmål strings + │ └── nn.ts # Nynorsk strings + ├── util/ + │ └── distance-utils.ts # Haversine distance, bearing, formatting + └── main.css # Dark theme, Leaflet overrides, dialogs +``` + +### Build System + +**Vite** bundles the app with content-hashed filenames for cache busting. + +**Key configuration:** +- Base path: `./` (relative, deployable anywhere) +- `__BUILD_REVISION__` define: ISO timestamp injected at build time, used to invalidate service worker caches +- **vite-plugin-pwa**: Generates the service worker with Workbox. Precaches all static assets. Runtime-caches OSM tile requests with CacheFirst strategy (30-day expiry, max 5000 entries). + +**Build-time data preprocessing:** +`scripts/fetch-shelters.ts` downloads the Geonorge ZIP, extracts the GeoJSON, converts UTM33N→WGS84, validates, and writes `public/data/shelters.json`. This means coordinate conversion is a build step, not a runtime cost. + +```bash +bun run fetch-shelters # Download and convert shelter data +bun run build # TypeScript check + Vite build + SW generation +``` + +### Data Layer (PWA) + +**Storage:** IndexedDB via the `idb` library. + +**Schema:** +- Object store `shelters` (keyPath: `lokalId`) — full shelter records +- Object store `metadata` — `lastUpdate` timestamp + +**Loading strategy:** +1. Check IndexedDB for cached shelters +2. If empty or stale (>7 days): fetch `shelters.json` (precached by service worker) +3. Replace all records in a single transaction +4. Reactive: UI reads from IndexedDB after load + +Unlike the Android app, the PWA does not perform coordinate conversion at runtime — `shelters.json` is pre-converted at build time and served as a static asset. + +### Location & Compass (PWA) + +**Location:** `navigator.geolocation.watchPosition()` with high accuracy enabled, 10s maximum age, 30s timeout. + +**Compass:** DeviceOrientationEvent with platform-specific handling: + +| Platform | API | Heading Source | +|----------------|------------------------------|--------------------------| +| iOS Safari | `deviceorientation` | `webkitCompassHeading` | +| Android Chrome | `deviceorientationabsolute` | `(360 − alpha) % 360` | + +iOS 13+ requires an explicit permission request (`DeviceOrientationEvent.requestPermission()`), triggered by a user gesture. + +Low-pass filter (smoothing factor 0.3) with 0/360° wraparound handling ensures fluid rotation. + +### Map & Offline Tiles (PWA) + +**Map library:** Leaflet with OpenStreetMap tiles. No CDN — Leaflet is bundled from `node_modules`. + +**Custom markers:** SVG-based `divIcon` elements — orange house with "T" for shelters, blue circle for user location. Selected shelter uses a larger yellow marker. + +**Auto-fit logic:** When a shelter is selected, the map fits bounds to show both user and shelter (30% padding), unless the user has manually panned or zoomed. A "reset view" button appears after manual interaction. + +**Tile pre-caching:** `MapCacheManager` uses the same approach as the Android app — programmatically pans the map across a 3×3 grid at 4 zoom levels. The service worker's runtime cache intercepts and stores the tile requests. Cache location stored in localStorage (rounded to ~11km precision for privacy). + +### Service Worker + +Generated by vite-plugin-pwa (Workbox): + +- **Precaching:** All static assets (JS, CSS, HTML, JSON, images) with content-hash versioning +- **Runtime caching:** OSM tiles from `{a,b,c}.tile.openstreetmap.org` with CacheFirst strategy, 30-day TTL, max 5000 entries +- **Navigation fallback:** Serves `index.html` for all navigation requests (SPA behavior) +- **Auto-update:** New service worker activates automatically; `controllerchange` event notifies the user + +`main.ts` injects the build revision into the service worker context, ensuring each build invalidates stale caches. It also requests persistent storage (`navigator.storage.persist()`) to prevent the browser from evicting cached data. + +### UI Components + +The PWA uses no framework — all UI is vanilla TypeScript manipulating the DOM. + +| Component | Description | +|--------------------------|----------------------------------------------------------| +| `app.ts` | Main controller: wires components, manages state | +| `map-view.ts` | Leaflet map with custom markers, auto-fit, interaction tracking | +| `compass-view.ts` | Canvas-rendered arrow with north indicator, requestAnimationFrame | +| `shelter-list.ts` | Bottom sheet with 3 nearest shelters, click selection | +| `loading-overlay.ts` | Modal: spinner during load, OK/Skip for cache prompt | +| `about-dialog.ts` | Privacy statement, data sources, "clear cache" button | +| `civil-defense-dialog.ts`| DSB emergency instructions (5 steps) | +| `status-bar.ts` | Data freshness indicator, refresh button | + +Dialogs are created dynamically and implement focus management (save/restore previous focus, trap focus inside modal). + +--- + +## Shared Algorithms + +Both platforms implement these algorithms independently (no shared code), ensuring each platform has zero runtime dependencies on the other. + +### UTM33N → WGS84 Conversion + +**Algorithm:** Karney series expansion method. + +Converts EUREF89 / UTM zone 33N (EPSG:25833) to WGS84 (EPSG:4326). + +**Constants:** +- Semi-major axis: 6,378,137 m +- Flattening: 1/298.257223563 +- UTM zone 33 central meridian: 15° E +- Scale factor: 0.9996 +- False easting: 500,000 m + +**Steps:** +1. Remove false easting/northing +2. Compute footprint latitude using series expansion +3. Apply iterative corrections for latitude and longitude +4. Add central meridian offset + +**Validation:** Reject results outside Norway bounding box (57–72°N, 3–33°E). + +**Implementations:** +- Android: `util/CoordinateConverter.kt` (runtime) +- PWA: `scripts/fetch-shelters.ts` (build-time) + +### Haversine Distance & Bearing + +**Distance** between two WGS84 points: +``` +a = sin²(Δφ/2) + cos(φ₁) · cos(φ₂) · sin²(Δλ/2) +d = 2R · atan2(√a, √(1−a)) +``` +Where R = 6,371 km (Earth mean radius). + +**Initial bearing** from point 1 to point 2: +``` +θ = atan2(sin(Δλ) · cos(φ₂), cos(φ₁) · sin(φ₂) − sin(φ₁) · cos(φ₂) · cos(Δλ)) +``` +Result in degrees: 0° = north, 90° = east, 180° = south, 270° = west. + +**Implementations:** +- Android: `util/DistanceUtils.kt` +- PWA: `util/distance-utils.ts` + +--- + +## Offline Capability Summary + +| Capability | Android | PWA | +|-------------------------|----------------------------------|------------------------------------| +| Shelter data | Room DB + bundled asset seed | IndexedDB + precached JSON | +| Map tiles | OSMDroid SqlTileWriter + active pre-cache | Service worker CacheFirst + active pre-cache | +| GPS | Device hardware (no network) | Device hardware (no network) | +| Compass | Device sensors | DeviceOrientationEvent | +| Distance/bearing math | Local computation | Local computation | +| Network required for | Initial refresh, new tiles | Initial page load, new tiles | +| Staleness threshold | 7 days | 7 days | + +--- + +## Security & Privacy + +- **No tracking:** No analytics, no telemetry, no external requests beyond Geonorge data and OSM tiles +- **No user accounts:** No registration, no login, no server-side storage +- **Location stays local:** GPS coordinates are used only for distance calculation and never transmitted +- **HTTPS enforced:** Android network security config restricts to TLS; PWA CSP headers restrict connections +- **ZIP bomb protection:** 10MB limit on uncompressed GeoJSON (Android parser) +- **CSP headers (PWA):** Restrictive Content-Security-Policy blocks external scripts; `img-src` allows only OSM tile servers +- **Input validation:** Shelter data validated at parse time (bounds check, required fields, non-negative capacity) +- **Minimal permissions:** Location only; no contacts, camera, storage (except legacy tile cache on Android ≤9) + +--- + +## Internationalization + +Both platforms support three languages: + +| Language | Android | PWA | +|---------------------|----------------------|----------------| +| English (default) | `values/strings.xml` | `i18n/en.ts` | +| Norwegian Bokmål | `values-nb/strings.xml` | `i18n/nb.ts` | +| Norwegian Nynorsk | `values-nn/strings.xml` | `i18n/nn.ts` | + +**Android:** Uses Android's built-in locale resolution. Per-app language selection supported on Android 13+ via `locales_config.xml`. + +**PWA:** Detects browser language (`navigator.language`). String substitution supports `%d` (number) and `%s` (string) placeholders. Falls back to English for unsupported locales.