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.
#### Deep link identifier — why `romnr`, not `lokalId`
The path component is the shelter's `romnr` (DSB room number — an integer like `776`), not the GeoJSON `lokalId` UUID, even though `lokalId` is what Room and Leaflet use as the in-memory primary key.
**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 would lose the selection if it was tracked by lokalId.
`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.
The internal Room primary key remains `lokalId` because (a) it's already the upstream-supplied UUID and changing it would force a destructive Room schema migration, and (b) within a single fetch it's a perfectly fine in-memory key. Only the *external* deep-link identifier was switched.
└── 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:
**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.