Bytt djuplenkjer frå tilfluktsrom:// til HTTPS App Links

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) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-23 16:37:13 +01:00
commit 015bc0d926
14 changed files with 114 additions and 15 deletions

View file

@ -215,9 +215,16 @@ Both flavors produce identical user experiences — `standard` achieves faster G
### Deep Linking ### 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.
--- ---

View file

@ -16,6 +16,11 @@ android {
targetSdk = 35 targetSdk = 35
versionCode = 11 versionCode = 11
versionName = "1.7.0" 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 { dependenciesInfo {

View file

@ -29,13 +29,14 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter> <intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data <data
android:scheme="tilfluktsrom" android:scheme="https"
android:host="shelter" /> android:host="${deepLinkHost}"
android:pathPrefix="/shelter/" />
</intent-filter> </intent-filter>
</activity> </activity>

View file

@ -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. * If shelters are already loaded, select immediately; otherwise store as pending.
*/ */
private fun handleDeepLinkIntent(intent: Intent?) { private fun handleDeepLinkIntent(intent: Intent?) {
val uri = intent?.data ?: return 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 val lokalId = uri.lastPathSegment ?: return
// Clear intent data so config changes don't re-trigger // 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. * Share the currently selected shelter via ACTION_SEND.
* Includes address, capacity, geo: URI (for non-app recipients), * Includes address, capacity, geo: URI, and an HTTPS deep link
* and a tilfluktsrom:// deep link (for app users). * that opens the app (if installed) or the PWA (in browser).
*/ */
private fun shareShelter() { private fun shareShelter() {
val selected = selectedShelter val selected = selectedShelter
@ -680,12 +682,14 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
} }
val shelter = selected.shelter val shelter = selected.shelter
val deepLink = "https://${BuildConfig.DEEP_LINK_DOMAIN}/shelter/${shelter.lokalId}"
val body = getString( val body = getString(
R.string.share_body, R.string.share_body,
shelter.adresse, shelter.adresse,
shelter.plasser, shelter.plasser,
shelter.latitude, shelter.latitude,
shelter.longitude shelter.longitude,
deepLink
) )
val shareIntent = Intent(Intent.ACTION_SEND).apply { val shareIntent = Intent(Intent.ACTION_SEND).apply {

View file

@ -60,7 +60,7 @@
<!-- Deling --> <!-- Deling -->
<string name="share_subject">Tilfluktsrom</string> <string name="share_subject">Tilfluktsrom</string>
<string name="share_body">Tilfluktsrom: %1$s\n%2$d plasser\n%3$.6f, %4$.6f\ngeo:%3$.6f,%4$.6f</string> <string name="share_body">Tilfluktsrom: %1$s\n%2$d plasser\n%3$.6f, %4$.6f\ngeo:%3$.6f,%4$.6f\n%5$s</string>
<string name="share_no_shelter">Ingen tilfluktsrom valgt</string> <string name="share_no_shelter">Ingen tilfluktsrom valgt</string>
<!-- Tilgjengelighet --> <!-- Tilgjengelighet -->

View file

@ -60,7 +60,7 @@
<!-- Deling --> <!-- Deling -->
<string name="share_subject">Tilfluktsrom</string> <string name="share_subject">Tilfluktsrom</string>
<string name="share_body">Tilfluktsrom: %1$s\n%2$d plassar\n%3$.6f, %4$.6f\ngeo:%3$.6f,%4$.6f</string> <string name="share_body">Tilfluktsrom: %1$s\n%2$d plassar\n%3$.6f, %4$.6f\ngeo:%3$.6f,%4$.6f\n%5$s</string>
<string name="share_no_shelter">Ingen tilfluktsrom valt</string> <string name="share_no_shelter">Ingen tilfluktsrom valt</string>
<!-- Tilgjenge --> <!-- Tilgjenge -->

View file

@ -60,7 +60,7 @@
<!-- Sharing --> <!-- Sharing -->
<string name="share_subject">Emergency shelter</string> <string name="share_subject">Emergency shelter</string>
<string name="share_body">Shelter: %1$s\n%2$d places\n%3$.6f, %4$.6f\ngeo:%3$.6f,%4$.6f</string> <string name="share_body">Shelter: %1$s\n%2$d places\n%3$.6f, %4$.6f\ngeo:%3$.6f,%4$.6f\n%5$s</string>
<string name="share_no_shelter">No shelter selected</string> <string name="share_no_shelter">No shelter selected</string>
<!-- Accessibility --> <!-- Accessibility -->

View file

@ -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"
]
}
}
]

View file

@ -9,7 +9,7 @@
import type { Shelter, ShelterWithDistance, LatLon } from './types'; import type { Shelter, ShelterWithDistance, LatLon } from './types';
import { t } from './i18n/i18n'; 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 { findNearest } from './location/shelter-finder';
import * as repo from './data/shelter-repository'; import * as repo from './data/shelter-repository';
import * as locationProvider from './location/location-provider'; import * as locationProvider from './location/location-provider';
@ -43,6 +43,7 @@ export async function init(): Promise<void> {
setupShelterList(); setupShelterList();
setupButtons(); setupButtons();
await loadData(); await loadData();
handleDeepLink();
} }
/** Set localized aria-labels and wire the about button. */ /** Set localized aria-labels and wire the about button. */
@ -398,3 +399,67 @@ async function forceRefresh(): Promise<void> {
statusBar.setStatus(t('update_failed')); 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);
}

2
pwa/src/config.ts Normal file
View file

@ -0,0 +1,2 @@
/** Deep link domain — single source of truth for the PWA. */
export const DEEP_LINK_DOMAIN = 'tilfluktsrom.naiv.no';

View file

@ -46,6 +46,7 @@ export const en: Record<string, string> = {
'No cached data available. Connect to the internet to download shelter data.', 'No cached data available. Connect to the internet to download shelter data.',
update_success: 'Shelter data updated', update_success: 'Shelter data updated',
update_failed: 'Update failed \u2014 using cached data', update_failed: 'Update failed \u2014 using cached data',
error_shelter_not_found: 'Shelter not found',
// Accessibility // Accessibility
direction_arrow_description: 'Direction to shelter, %s away', direction_arrow_description: 'Direction to shelter, %s away',

View file

@ -41,6 +41,7 @@ export const nb: Record<string, string> = {
'Ingen lagrede data tilgjengelig. Koble til internett for å laste ned tilfluktsromdata.', 'Ingen lagrede data tilgjengelig. Koble til internett for å laste ned tilfluktsromdata.',
update_success: 'Tilfluktsromdata oppdatert', update_success: 'Tilfluktsromdata oppdatert',
update_failed: 'Oppdatering mislyktes — bruker lagrede data', update_failed: 'Oppdatering mislyktes — bruker lagrede data',
error_shelter_not_found: 'Fant ikke tilfluktsrommet',
// Tilgjengelighet // Tilgjengelighet
direction_arrow_description: 'Retning til tilfluktsrom, %s unna', direction_arrow_description: 'Retning til tilfluktsrom, %s unna',

View file

@ -41,6 +41,7 @@ export const nn: Record<string, string> = {
'Ingen lagra data tilgjengeleg. Kopla til internett for å laste ned tilfluktsromdata.', 'Ingen lagra data tilgjengeleg. Kopla til internett for å laste ned tilfluktsromdata.',
update_success: 'Tilfluktsromdata oppdatert', update_success: 'Tilfluktsromdata oppdatert',
update_failed: 'Oppdatering mislukkast — brukar lagra data', update_failed: 'Oppdatering mislukkast — brukar lagra data',
error_shelter_not_found: 'Fann ikkje tilfluktsrommet',
// Tilgjenge // Tilgjenge
direction_arrow_description: 'Retning til tilfluktsrom, %s unna', direction_arrow_description: 'Retning til tilfluktsrom, %s unna',

View file

@ -2,7 +2,7 @@ import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa'; import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({ export default defineConfig({
base: './', base: '/',
define: { define: {
// Injected as a global — changes every build, breaking any stale cache // Injected as a global — changes every build, breaking any stale cache
__BUILD_REVISION__: JSON.stringify( __BUILD_REVISION__: JSON.stringify(