diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 073fa52..c544235 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -215,9 +215,16 @@ Both flavors produce identical user experiences — `standard` achieves faster G
### Deep Linking
-URI scheme: `tilfluktsrom://shelter/{lokalId}`
+**HTTPS App Links:** `https://tilfluktsrom.naiv.no/shelter/{lokalId}`
-Used in share messages so recipients can open the app directly to a specific shelter.
+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.
---
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index c46b0b4..5bbea30 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -16,6 +16,11 @@ android {
targetSdk = 35
versionCode = 11
versionName = "1.7.0"
+
+ // Deep link domain — single source of truth for manifest + Kotlin code
+ val deepLinkDomain = "tilfluktsrom.naiv.no"
+ buildConfigField("String", "DEEP_LINK_DOMAIN", "\"$deepLinkDomain\"")
+ manifestPlaceholders["deepLinkHost"] = deepLinkDomain
}
dependenciesInfo {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 58bf2ed..e522483 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -29,13 +29,14 @@
-
+
+ android:scheme="https"
+ android:host="${deepLinkHost}"
+ android:pathPrefix="/shelter/" />
diff --git a/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt b/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt
index e1fd8e0..5f80847 100644
--- a/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt
+++ b/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt
@@ -143,12 +143,14 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
}
/**
- * Handle tilfluktsrom://shelter/{lokalId} deep link.
+ * Handle https://{domain}/shelter/{lokalId} deep link.
* If shelters are already loaded, select immediately; otherwise store as pending.
*/
private fun handleDeepLinkIntent(intent: Intent?) {
val uri = intent?.data ?: return
- if (uri.scheme != "tilfluktsrom" || uri.host != "shelter") return
+ if (uri.scheme != "https" ||
+ uri.host != BuildConfig.DEEP_LINK_DOMAIN ||
+ uri.path?.startsWith("/shelter/") != true) return
val lokalId = uri.lastPathSegment ?: return
// Clear intent data so config changes don't re-trigger
@@ -669,8 +671,8 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
/**
* Share the currently selected shelter via ACTION_SEND.
- * Includes address, capacity, geo: URI (for non-app recipients),
- * and a tilfluktsrom:// deep link (for app users).
+ * Includes address, capacity, geo: URI, and an HTTPS deep link
+ * that opens the app (if installed) or the PWA (in browser).
*/
private fun shareShelter() {
val selected = selectedShelter
@@ -680,12 +682,14 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
}
val shelter = selected.shelter
+ val deepLink = "https://${BuildConfig.DEEP_LINK_DOMAIN}/shelter/${shelter.lokalId}"
val body = getString(
R.string.share_body,
shelter.adresse,
shelter.plasser,
shelter.latitude,
- shelter.longitude
+ shelter.longitude,
+ deepLink
)
val shareIntent = Intent(Intent.ACTION_SEND).apply {
diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml
index 74c464b..8f0f2d3 100644
--- a/app/src/main/res/values-nb/strings.xml
+++ b/app/src/main/res/values-nb/strings.xml
@@ -60,7 +60,7 @@
Tilfluktsrom
- Tilfluktsrom: %1$s\n%2$d plasser\n%3$.6f, %4$.6f\ngeo:%3$.6f,%4$.6f
+ Tilfluktsrom: %1$s\n%2$d plasser\n%3$.6f, %4$.6f\ngeo:%3$.6f,%4$.6f\n%5$s
Ingen tilfluktsrom valgt
diff --git a/app/src/main/res/values-nn/strings.xml b/app/src/main/res/values-nn/strings.xml
index f3077e5..088398a 100644
--- a/app/src/main/res/values-nn/strings.xml
+++ b/app/src/main/res/values-nn/strings.xml
@@ -60,7 +60,7 @@
Tilfluktsrom
- Tilfluktsrom: %1$s\n%2$d plassar\n%3$.6f, %4$.6f\ngeo:%3$.6f,%4$.6f
+ Tilfluktsrom: %1$s\n%2$d plassar\n%3$.6f, %4$.6f\ngeo:%3$.6f,%4$.6f\n%5$s
Ingen tilfluktsrom valt
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 38d09b2..34afc98 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -60,7 +60,7 @@
Emergency shelter
- Shelter: %1$s\n%2$d places\n%3$.6f, %4$.6f\ngeo:%3$.6f,%4$.6f
+ Shelter: %1$s\n%2$d places\n%3$.6f, %4$.6f\ngeo:%3$.6f,%4$.6f\n%5$s
No shelter selected
diff --git a/pwa/public/.well-known/assetlinks.json b/pwa/public/.well-known/assetlinks.json
new file mode 100644
index 0000000..b95a5f1
--- /dev/null
+++ b/pwa/public/.well-known/assetlinks.json
@@ -0,0 +1,12 @@
+[
+ {
+ "relation": ["delegate_permission/common.handle_all_urls"],
+ "target": {
+ "namespace": "android_app",
+ "package_name": "no.naiv.tilfluktsrom",
+ "sha256_cert_fingerprints": [
+ "43:05:79:6F:EA:3E:F4:50:45:D3:8A:EF:EA:58:B6:65:49:D2:D2:C3:4B:4C:61:11:EE:74:48:B0:C7:70:E4:5B"
+ ]
+ }
+ }
+]
diff --git a/pwa/src/app.ts b/pwa/src/app.ts
index f2bee0a..ceceb58 100644
--- a/pwa/src/app.ts
+++ b/pwa/src/app.ts
@@ -9,7 +9,7 @@
import type { Shelter, ShelterWithDistance, LatLon } from './types';
import { t } from './i18n/i18n';
-import { formatDistance } from './util/distance-utils';
+import { formatDistance, distanceMeters, bearingDegrees } from './util/distance-utils';
import { findNearest } from './location/shelter-finder';
import * as repo from './data/shelter-repository';
import * as locationProvider from './location/location-provider';
@@ -43,6 +43,7 @@ export async function init(): Promise {
setupShelterList();
setupButtons();
await loadData();
+ handleDeepLink();
}
/** Set localized aria-labels and wire the about button. */
@@ -398,3 +399,67 @@ async function forceRefresh(): Promise {
statusBar.setStatus(t('update_failed'));
}
}
+
+/**
+ * Handle /shelter/{lokalId} deep links.
+ * Called after loadData() so allShelters is populated.
+ */
+function handleDeepLink(): void {
+ const match = window.location.pathname.match(/^\/shelter\/(.+)$/);
+ if (!match) return;
+
+ const lokalId = decodeURIComponent(match[1]);
+
+ // Clean the URL so refresh doesn't re-trigger
+ window.history.replaceState({}, '', '/');
+
+ const shelter = allShelters.find((s) => s.lokalId === lokalId);
+ if (!shelter) {
+ statusBar.setStatus(t('error_shelter_not_found'));
+ return;
+ }
+
+ selectShelterByData(shelter);
+}
+
+/**
+ * Select a specific shelter, even if it's not in the current nearest-3 list.
+ * Used for deep link targets.
+ */
+function selectShelterByData(shelter: Shelter): void {
+ // Check if it's already in nearestShelters
+ const existingIdx = nearestShelters.findIndex(
+ (s) => s.shelter.lokalId === shelter.lokalId,
+ );
+
+ if (existingIdx >= 0) {
+ userSelectedShelter = true;
+ selectedShelterIndex = existingIdx;
+ } else {
+ // 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);
+}
diff --git a/pwa/src/config.ts b/pwa/src/config.ts
new file mode 100644
index 0000000..a0ccb93
--- /dev/null
+++ b/pwa/src/config.ts
@@ -0,0 +1,2 @@
+/** Deep link domain — single source of truth for the PWA. */
+export const DEEP_LINK_DOMAIN = 'tilfluktsrom.naiv.no';
diff --git a/pwa/src/i18n/en.ts b/pwa/src/i18n/en.ts
index 1630463..6a77154 100644
--- a/pwa/src/i18n/en.ts
+++ b/pwa/src/i18n/en.ts
@@ -46,6 +46,7 @@ export const en: Record = {
'No cached data available. Connect to the internet to download shelter data.',
update_success: 'Shelter data updated',
update_failed: 'Update failed \u2014 using cached data',
+ error_shelter_not_found: 'Shelter not found',
// Accessibility
direction_arrow_description: 'Direction to shelter, %s away',
diff --git a/pwa/src/i18n/nb.ts b/pwa/src/i18n/nb.ts
index 2ccca12..8c285d8 100644
--- a/pwa/src/i18n/nb.ts
+++ b/pwa/src/i18n/nb.ts
@@ -41,6 +41,7 @@ export const nb: Record = {
'Ingen lagrede data tilgjengelig. Koble til internett for å laste ned tilfluktsromdata.',
update_success: 'Tilfluktsromdata oppdatert',
update_failed: 'Oppdatering mislyktes — bruker lagrede data',
+ error_shelter_not_found: 'Fant ikke tilfluktsrommet',
// Tilgjengelighet
direction_arrow_description: 'Retning til tilfluktsrom, %s unna',
diff --git a/pwa/src/i18n/nn.ts b/pwa/src/i18n/nn.ts
index 8acc662..eace6bd 100644
--- a/pwa/src/i18n/nn.ts
+++ b/pwa/src/i18n/nn.ts
@@ -41,6 +41,7 @@ export const nn: Record = {
'Ingen lagra data tilgjengeleg. Kopla til internett for å laste ned tilfluktsromdata.',
update_success: 'Tilfluktsromdata oppdatert',
update_failed: 'Oppdatering mislukkast — brukar lagra data',
+ error_shelter_not_found: 'Fann ikkje tilfluktsrommet',
// Tilgjenge
direction_arrow_description: 'Retning til tilfluktsrom, %s unna',
diff --git a/pwa/vite.config.ts b/pwa/vite.config.ts
index 8d5b971..4df550a 100644
--- a/pwa/vite.config.ts
+++ b/pwa/vite.config.ts
@@ -2,7 +2,7 @@ import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
- base: './',
+ base: '/',
define: {
// Injected as a global — changes every build, breaking any stale cache
__BUILD_REVISION__: JSON.stringify(