From 015bc0d926a6f7f2863fada9ff6e38d13f9c6cba Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 23 Mar 2026 16:37:13 +0100 Subject: [PATCH] =?UTF-8?q?Bytt=20djuplenkjer=20fr=C3=A5=20tilfluktsrom://?= =?UTF-8?q?=20til=20HTTPS=20App=20Links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ARCHITECTURE.md | 11 ++- app/build.gradle.kts | 5 ++ app/src/main/AndroidManifest.xml | 7 +- .../java/no/naiv/tilfluktsrom/MainActivity.kt | 14 ++-- app/src/main/res/values-nb/strings.xml | 2 +- app/src/main/res/values-nn/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- pwa/public/.well-known/assetlinks.json | 12 ++++ pwa/src/app.ts | 67 ++++++++++++++++++- pwa/src/config.ts | 2 + pwa/src/i18n/en.ts | 1 + pwa/src/i18n/nb.ts | 1 + pwa/src/i18n/nn.ts | 1 + pwa/vite.config.ts | 2 +- 14 files changed, 114 insertions(+), 15 deletions(-) create mode 100644 pwa/public/.well-known/assetlinks.json create mode 100644 pwa/src/config.ts 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(