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.
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.
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, and OkHttp.
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:
-`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.
| 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.
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.
**Decision (2026-08-17, Forgejo #15):** `romnr` — DSB's room number — is the authoritative shelter identifier everywhere: Room primary key, IndexedDB `keyPath`, marker maps, list diffing, selection equality and the deep-link path. `lokalId` is stored as informational data only and must never be compared or keyed on. Records without a positive `romnr` are skipped by both parsers.
The deep-link path component is therefore `romnr` (an integer like `776`), not the GeoJSON `lokalId` UUID.
**Empirical reason:** the upstream GeoJSON ZIP at `nedlasting.geonorge.no/.../TilfluktsromOffentlige_GeoJSON.zip` re-rolls every `lokalId` on every export. Three snapshots of the same dataset (taken Dec 2025, Apr 20 2026, Apr 27 2026) had **556/556 different lokalIds** while every other field (`romnr`, `adresse`, `plasser`, `latitude`, `longitude`) was byte-identical and the shelter count was unchanged. The most recent two snapshots are only seven days apart, so this is regular drift, not a one-off re-issue.
That makes `lokalId` unsuitable for any cross-device or cross-build identifier:
- Sender and receiver who fetched the dataset on different days have different lokalIds for the same physical shelter, so a lokalId-keyed share link fails with "shelter not found" on the receiving side.
- Even on a single device, a user who hits "Refresh data" while a shelter is selected keeps the *object* but its `lokalId` no longer matches anything in the new dataset — before the switch this produced a duplicate "outside nearest" list row and a lost marker highlight on Android.
`romnr` is the actual DSB business key — the room number physically assigned to the shelter by the civil-defence authority — and is stable across exports. Verified unique (556/556) and present (no zeros) on the current dataset.
Room schema v2 and IndexedDB v2 switched the primary key to `romnr` (destructive migration in both — the tables are pure caches re-seeded from the bundled asset / `data/shelters.json` when empty). The weekly romnr-stability routine (`scripts/romnr-baseline.json`) remains the safety net: it diffs each Geonorge export against the baseline and flags any romnr that disappears, appears or moves.
└── 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
- 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:
**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.
**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.