SMS-appar gjenkjenner ikkje eigendefinerte URI-skjema som klikkbare lenkjer. Brukar no https://tilfluktsrom.naiv.no/shelter/{id} som opnar appen direkte (Android App Links med autoVerify) eller fell tilbake til PWA i nettlesaren. Android: DEEP_LINK_DOMAIN i build.gradle.kts, HTTPS intent-filter, oppdatert handleDeepLinkIntent og shareShelter med HTTPS-URL. PWA: assetlinks.json for Android-verifisering, djuplenkjehandtering i app.ts, base-sti endra frå './' til '/', config.ts for domene. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
455 lines
21 KiB
Markdown
455 lines
21 KiB
Markdown
# 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
|
||
|
||
**HTTPS App Links:** `https://tilfluktsrom.naiv.no/shelter/{lokalId}`
|
||
|
||
The domain is configured in one place: `DEEP_LINK_DOMAIN` in `build.gradle.kts` (exposed as `BuildConfig.DEEP_LINK_DOMAIN` and manifest placeholder `${deepLinkHost}`).
|
||
|
||
- `autoVerify="true"` on the HTTPS intent filter triggers Android's App Links verification at install time
|
||
- Verification requires `/.well-known/assetlinks.json` to be served by the PWA (in `pwa/public/.well-known/`)
|
||
- If the app is installed and verified, `/shelter/*` links open the app directly (no disambiguation dialog)
|
||
- If not installed, the link opens in the browser, where the PWA handles it
|
||
|
||
Share messages include the HTTPS URL, which SMS apps auto-link as a tappable URL.
|
||
|
||
---
|
||
|
||
## 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.
|