Compare commits

..

No commits in common. "948625b777767153cfeb405ec37c958986cef7ff" and "87ac698d55d35ae9ca4d31a9e01c5ca494c0c54c" have entirely different histories.

15 changed files with 618 additions and 801 deletions

View file

@ -196,7 +196,7 @@ Both flavors produce identical user experiences — `standard` achieves faster G
### Deep Linking
**HTTPS App Links:** `https://tilfluktsrom.naiv.no/shelter/{romnr}`
**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}`).
@ -207,20 +207,6 @@ The domain is configured in one place: `DEEP_LINK_DOMAIN` in `build.gradle.kts`
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.
---
## Progressive Web App

View file

@ -14,8 +14,8 @@ android {
applicationId = "no.naiv.tilfluktsrom"
minSdk = 26
targetSdk = 35
versionCode = 16
versionName = "1.10.1"
versionCode = 15
versionName = "1.10.0"
// Deep link domain — single source of truth for manifest + Kotlin code
val deepLinkDomain = "tilfluktsrom.naiv.no"

File diff suppressed because it is too large Load diff

View file

@ -71,14 +71,6 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
private var deviceHeading = 0f
private var isCompassMode = false
private var cachingJob: Job? = null
private var refreshJob: Job? = null
// Whether to consider showing the map-cache prompt on the next location
// update. Mirrors the PWA's firstLocationFix flag: we only prompt once per
// session, regardless of whether the user accepts or skips. Without this
// guard, every location update re-checks hasCacheForLocation and re-shows
// the prompt if the user previously chose "Skip".
private var mapCachePromptPending = true
// Map from shelter lokalId to its map marker, for icon swapping on selection
private var shelterMarkerMap: MutableMap<String, Marker> = mutableMapOf()
private var highlightedMarkerId: String? = null
@ -86,14 +78,8 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
// Whether a compass sensor is available on this device
private var hasCompassSensor = false
// Deep link: shelter to select once data is loaded.
// We key on `romnr` (DSB's room number) rather than `lokalId` because
// upstream Geonorge re-rolls the lokalId UUID on every export. Two
// devices that fetched at different times have different lokalIds for
// the same physical shelter, breaking cross-device share links.
// Romnr is the actual DSB business key and is stable across exports.
// See ARCHITECTURE.md → "Deep link identifier".
private var pendingDeepLinkRomnr: Int? = null
// Deep link: shelter ID to select once data is loaded
private var pendingDeepLinkShelterId: String? = null
// The currently selected shelter — can be any shelter, not just one from nearestShelters
private var selectedShelter: ShelterWithDistance? = null
@ -145,9 +131,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
}
/**
* Handle https://{domain}/shelter/{romnr} deep link.
* `romnr` is DSB's stable shelter room number see field comment on
* pendingDeepLinkRomnr for why we don't use lokalId.
* Handle https://{domain}/shelter/{lokalId} deep link.
* If shelters are already loaded, select immediately; otherwise store as pending.
*/
private fun handleDeepLinkIntent(intent: Intent?) {
@ -156,15 +140,15 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
uri.host != BuildConfig.DEEP_LINK_DOMAIN ||
uri.path?.startsWith("/shelter/") != true) return
val romnr = uri.lastPathSegment?.toIntOrNull() ?: return
val lokalId = uri.lastPathSegment ?: return
// Clear intent data so config changes don't re-trigger
intent.data = null
val shelter = allShelters.find { it.romnr == romnr }
val shelter = allShelters.find { it.lokalId == lokalId }
if (shelter != null) {
selectShelterByData(shelter)
} else {
pendingDeepLinkRomnr = romnr
pendingDeepLinkShelterId = lokalId
}
}
@ -297,9 +281,9 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
updateShelterMarkers()
// Process pending deep links now that shelter data is available
pendingDeepLinkRomnr?.let { romnr ->
pendingDeepLinkRomnr = null
val shelter = shelters.find { it.romnr == romnr }
pendingDeepLinkShelterId?.let { id ->
pendingDeepLinkShelterId = null
val shelter = shelters.find { it.lokalId == id }
if (shelter != null) {
selectShelterByData(shelter)
} else {
@ -434,14 +418,11 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
)
}
// Cache map tiles on first launch — at most one prompt
// per session so a "Skip" decision sticks.
if (mapCachePromptPending &&
!mapCacheManager.hasCacheForLocation(location.latitude, location.longitude) &&
isNetworkAvailable()
) {
mapCachePromptPending = false
cacheMapTiles(location.latitude, location.longitude)
// Cache map tiles on first launch
if (!mapCacheManager.hasCacheForLocation(location.latitude, location.longitude)) {
if (isNetworkAvailable()) {
cacheMapTiles(location.latitude, location.longitude)
}
}
}
} catch (e: CancellationException) {
@ -700,26 +681,14 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
return
}
// Guard against double-tap / overlapping refreshes. Without this, the
// user can fire several refreshData() calls that serialize on the
// single Room write-lock and stack 3090 s of OkHttp timeouts on top
// of each other — perceived as "hang" with no feedback.
if (refreshJob?.isActive == true) return
binding.statusText.text = getString(R.string.status_updating)
showLoading(getString(R.string.loading_shelters))
refreshJob = lifecycleScope.launch {
try {
val success = repository.refreshData()
if (success) {
updateFreshnessIndicator()
Toast.makeText(this@MainActivity, R.string.update_success, Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this@MainActivity, R.string.update_failed, Toast.LENGTH_SHORT).show()
}
} finally {
hideLoading()
lifecycleScope.launch {
binding.statusText.text = getString(R.string.status_updating)
val success = repository.refreshData()
if (success) {
updateFreshnessIndicator()
Toast.makeText(this@MainActivity, R.string.update_success, Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this@MainActivity, R.string.update_failed, Toast.LENGTH_SHORT).show()
}
}
}
@ -737,10 +706,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
}
val shelter = selected.shelter
// Path component is romnr (stable DSB business key), not lokalId —
// upstream re-rolls lokalId on every Geonorge export, which would
// break cross-device links. See pendingDeepLinkRomnr comment.
val deepLink = "https://${BuildConfig.DEEP_LINK_DOMAIN}/shelter/${shelter.romnr}"
val deepLink = "https://${BuildConfig.DEEP_LINK_DOMAIN}/shelter/${shelter.lokalId}"
val body = getString(
R.string.share_body,
shelter.adresse,

View file

@ -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.10.1")
.header("User-Agent", "Tilfluktsrom/1.10.0")
.build())
})
.build()

View file

@ -1,4 +0,0 @@
- Deep links now use the shelter room number, which is stable across data refreshes — links shared between devices work reliably
- Refresh button shows a loading indicator and no longer appears to hang
- "Cache map" prompt no longer reappears after you tap Skip
- Refreshed bundled shelter data

View file

@ -1,4 +0,0 @@
- Delingslenker bruker nå romnummeret, som er stabilt på tvers av dataoppdateringer — lenker som deles mellom enheter virker pålitelig
- Oppdater-knappen viser en lasteindikator og ser ikke lenger ut til å henge
- "Lagre kart"-spørsmålet dukker ikke lenger opp igjen etter at du har trykket Hopp over
- Oppdatert pakket tilfluktsromdata

View file

@ -1,4 +0,0 @@
- Delingslenker brukar no romnummeret, som er stabilt på tvers av dataoppdateringar — lenker som blir delte mellom einingar verkar pålitelig
- Oppdater-knappen viser ein lasteindikator og ser ikkje lenger ut til å henge
- "Lagre kart"-spørsmålet dukkar ikkje opp att etter at du har trykt Hopp over
- Oppdatert pakka tilfluktsromdata

View file

@ -25,7 +25,6 @@
<header id="status-bar" role="banner">
<span id="status-text" aria-live="polite"></span>
<button id="about-btn" aria-label="About">&#x2139;</button>
<button id="share-btn" aria-label="Share shelter">&#x21AA;</button>
<button id="refresh-btn" aria-label="Refresh data">&#x21bb;</button>
</header>

View file

@ -11,7 +11,6 @@ import type { Shelter, ShelterWithDistance, LatLon } from './types';
import { t } from './i18n/i18n';
import { formatDistance, distanceMeters, bearingDegrees } from './util/distance-utils';
import { findNearest } from './location/shelter-finder';
import { DEEP_LINK_DOMAIN } from './config';
import * as repo from './data/shelter-repository';
import * as locationProvider from './location/location-provider';
import * as compassProvider from './location/compass-provider';
@ -36,12 +35,6 @@ let firstLocationFix = true;
// Track whether user manually selected a shelter (prevents auto-reselection
// on location updates)
let userSelectedShelter = false;
// Romnr (DSB room number) of the user-selected shelter — survives location
// updates that recompute nearestShelters (deep links, marker taps to a
// far-away shelter), and *also* survives a forceRefresh() that replaces
// every lokalId in the dataset. Romnr is the stable upstream business key;
// see ARCHITECTURE.md → "Deep link identifier" for rationale.
let selectedRomnr: number | null = null;
export async function init(): Promise<void> {
applyA11yLabels();
@ -65,16 +58,21 @@ function applyA11yLabels(): void {
document.getElementById('bottom-sheet')?.setAttribute('aria-label', t('a11y_shelter_info'));
document.getElementById('shelter-list')?.setAttribute('aria-label', t('a11y_nearest_shelters'));
document.getElementById('refresh-btn')?.setAttribute('aria-label', t('action_refresh'));
document.getElementById('share-btn')?.setAttribute('aria-label', t('action_share'));
document.getElementById('toggle-fab')?.setAttribute('aria-label', t('action_toggle_view'));
}
function setupMap(): void {
const container = document.getElementById('map-container')!;
mapView.initMap(container, (shelter: Shelter) => {
// Marker click — select this shelter (route through selectShelterByData
// so a tap on a far-away marker also survives location updates).
selectShelterByData(shelter);
// Marker click — select this shelter
const idx = nearestShelters.findIndex(
(s) => s.shelter.lokalId === shelter.lokalId,
);
if (idx >= 0) {
userSelectedShelter = true;
selectedShelterIndex = idx;
updateSelectedShelter(true);
}
});
}
@ -88,7 +86,6 @@ function setupShelterList(): void {
shelterList.initShelterList(container, (index: number) => {
userSelectedShelter = true;
selectedShelterIndex = index;
selectedRomnr = nearestShelters[index]?.shelter.romnr ?? null;
updateSelectedShelter(true);
});
}
@ -135,14 +132,6 @@ function setupButtons(): void {
// Refresh button
statusBar.onRefreshClick(forceRefresh);
// Share button — emits the same HTTPS deep link the Android app uses,
// so a recipient with the app installed (and verified App Links) opens
// the shelter natively, otherwise it opens in the PWA.
document.getElementById('share-btn')?.addEventListener('click', () => {
navigator.vibrate?.(10);
shareSelectedShelter();
});
// Cache retry button
const cacheRetryBtn = document.getElementById('cache-retry-btn')!;
cacheRetryBtn.textContent = t('action_cache_now');
@ -258,40 +247,8 @@ function updateNearestShelters(location: LatLon): void {
NEAREST_COUNT,
);
if (userSelectedShelter && selectedRomnr !== null) {
// Preserve the user's chosen shelter (deep link, marker click, list tap)
// even when it isn't in the geographic top-N. We re-add it with a
// freshly computed distance/bearing so the arrow stays correct.
// Match by romnr — survives a forceRefresh() that replaces lokalIds.
const inList = nearestShelters.findIndex(
(s) => s.shelter.romnr === selectedRomnr,
);
if (inList >= 0) {
selectedShelterIndex = inList;
} else {
const shelter = allShelters.find((s) => s.romnr === selectedRomnr);
if (shelter) {
nearestShelters.unshift({
shelter,
distanceMeters: distanceMeters(
location.latitude, location.longitude,
shelter.latitude, shelter.longitude,
),
bearingDegrees: bearingDegrees(
location.latitude, location.longitude,
shelter.latitude, shelter.longitude,
),
});
selectedShelterIndex = 0;
} else {
// Selected shelter no longer exists in the dataset (e.g. DSB
// decommissioned it). Fall back to nearest.
selectedRomnr = null;
userSelectedShelter = false;
selectedShelterIndex = 0;
}
}
} else {
// Only auto-select the nearest shelter if the user hasn't manually selected one
if (!userSelectedShelter) {
selectedShelterIndex = 0;
}
@ -448,22 +405,19 @@ async function forceRefresh(): Promise<void> {
}
/**
* Handle /shelter/{romnr} deep links.
* Handle /shelter/{lokalId} deep links.
* Called after loadData() so allShelters is populated.
*
* Path component is romnr (DSB room number), not lokalId see
* selectedRomnr comment above for the upstream-stability rationale.
*/
function handleDeepLink(): void {
const match = window.location.pathname.match(/^\/shelter\/(\d+)$/);
const match = window.location.pathname.match(/^\/shelter\/(.+)$/);
if (!match) return;
const romnr = parseInt(match[1], 10);
const lokalId = decodeURIComponent(match[1]);
// Clean the URL so refresh doesn't re-trigger
window.history.replaceState({}, '', '/');
const shelter = allShelters.find((s) => s.romnr === romnr);
const shelter = allShelters.find((s) => s.lokalId === lokalId);
if (!shelter) {
statusBar.setStatus(t('error_shelter_not_found'));
return;
@ -474,95 +428,42 @@ function handleDeepLink(): void {
/**
* Select a specific shelter, even if it's not in the current nearest-3 list.
* Used for deep link targets, marker taps, and list taps. The selection is
* remembered via selectedLokalId so subsequent location updates preserve it.
* Used for deep link targets.
*/
function selectShelterByData(shelter: Shelter): void {
userSelectedShelter = true;
selectedRomnr = shelter.romnr;
// Check if it's already in nearestShelters
const existingIdx = nearestShelters.findIndex(
(s) => s.shelter.romnr === shelter.romnr,
(s) => s.shelter.lokalId === shelter.lokalId,
);
if (existingIdx >= 0) {
userSelectedShelter = true;
selectedShelterIndex = existingIdx;
} else {
// Compute distance/bearing if we have a location, otherwise NaN signals
// "unknown distance" — same convention as MainActivity.kt.
const dist = currentLocation
? distanceMeters(
currentLocation.latitude, currentLocation.longitude,
shelter.latitude, shelter.longitude,
)
: NaN;
const bearing = currentLocation
? bearingDegrees(
currentLocation.latitude, currentLocation.longitude,
shelter.latitude, shelter.longitude,
)
: NaN;
// Compute distance/bearing if we have a location, otherwise use placeholder
let dist = NaN;
let bearing = 0;
if (currentLocation) {
dist = distanceMeters(
currentLocation.latitude, currentLocation.longitude,
shelter.latitude, shelter.longitude,
);
bearing = bearingDegrees(
currentLocation.latitude, currentLocation.longitude,
shelter.latitude, shelter.longitude,
);
}
// Prepend to the list so it becomes the selected shelter
nearestShelters.unshift({
shelter,
distanceMeters: dist,
bearingDegrees: bearing,
});
userSelectedShelter = true;
selectedShelterIndex = 0;
shelterList.updateList(nearestShelters, selectedShelterIndex);
}
updateSelectedShelter(true);
}
/**
* Share the currently selected shelter. Uses the Web Share API when
* available (mobile browsers, installed PWAs) and falls back to copying
* the same body to the clipboard. Body format mirrors share_body in the
* Android strings.xml so the two clients produce equivalent messages.
*/
async function shareSelectedShelter(): Promise<void> {
const selected = nearestShelters[selectedShelterIndex];
if (!selected || !selected.shelter) {
statusBar.setStatus(t('share_no_shelter'));
return;
}
const shelter = selected.shelter;
const lat = shelter.latitude.toFixed(6);
const lon = shelter.longitude.toFixed(6);
// Path component is romnr (stable DSB business key), not lokalId. The
// upstream Geonorge GeoJSON re-rolls lokalId on every export, so a
// lokalId-keyed link breaks the moment either party refreshes their
// dataset. Romnr is unique (verified 556/556) and stable across exports.
const deepLink = `https://${DEEP_LINK_DOMAIN}/shelter/${shelter.romnr}`;
const subject = t('share_subject');
const body = [
`${subject}: ${shelter.adresse}`,
t('shelter_capacity', shelter.plasser),
`${lat}, ${lon}`,
`geo:${lat},${lon}`,
deepLink,
].join('\n');
if (navigator.share) {
try {
await navigator.share({ title: subject, text: body, url: deepLink });
return;
} catch (err) {
// AbortError means the user cancelled — silent. Anything else falls
// through to the clipboard fallback below.
if ((err as DOMException)?.name === 'AbortError') return;
}
}
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(body);
statusBar.setStatus(t('share_copied'));
} catch {
statusBar.setStatus(t('share_no_shelter'));
}
}
}

View file

@ -30,10 +30,6 @@ export const en: Record<string, string> = {
action_skip: 'Skip',
action_cache_ok: 'Cache map',
action_cache_now: 'Cache now',
action_share: 'Share shelter',
share_subject: 'Emergency shelter',
share_no_shelter: 'No shelter selected',
share_copied: 'Shelter details copied to clipboard',
warning_no_map_cache: 'No offline map cached. Map requires internet.',
// Permissions

View file

@ -26,11 +26,7 @@ export function initLocale(): void {
break;
}
}
// Guard the DOM write so this module is usable from Node (vitest runs
// without jsdom). In a browser, document is always defined.
if (typeof document !== 'undefined') {
document.documentElement.lang = currentLocale;
}
document.documentElement.lang = currentLocale;
}
/** Get current locale code. */

View file

@ -26,10 +26,6 @@ export const nb: Record<string, string> = {
action_skip: 'Hopp over',
action_cache_ok: 'Lagre kart',
action_cache_now: 'Lagre nå',
action_share: 'Del tilfluktsrom',
share_subject: 'Tilfluktsrom',
share_no_shelter: 'Ingen tilfluktsrom valgt',
share_copied: 'Tilfluktsrominfo kopiert til utklippstavlen',
warning_no_map_cache:
'Ingen frakoblet kart lagret. Kartet krever internett.',

View file

@ -26,10 +26,6 @@ export const nn: Record<string, string> = {
action_skip: 'Hopp over',
action_cache_ok: 'Lagre kart',
action_cache_now: 'Lagre no',
action_share: 'Del tilfluktsrom',
share_subject: 'Tilfluktsrom',
share_no_shelter: 'Ingen tilfluktsrom valt',
share_copied: 'Tilfluktsrominfo kopiert til utklippstavla',
warning_no_map_cache:
'Ingen fråkopla kart lagra. Kartet krev internett.',

View file

@ -63,7 +63,6 @@ html, body {
}
#about-btn,
#share-btn,
#refresh-btn {
background: none;
border: none;
@ -76,16 +75,10 @@ html, body {
}
#about-btn:hover,
#share-btn:hover,
#refresh-btn:hover {
color: #ECEFF1;
}
#share-btn[disabled] {
opacity: 0.4;
cursor: not-allowed;
}
/* --- Main content area (map or compass) --- */
#main-content {
flex: 1;