Legg til om-side, personvernerklæring og sikkerheitsforbetring

Om-side (Android + PWA):
- Ny AboutDialog med personvernerklæring, datakjelder og opphavsrett
- Opphavsrett flytta frå sivilforsvardialogen til om-sida
- Tilgjengeleg via «Om denne appen»-lenke i sivilforsvarsdialogen (Android)
  og ny om-knapp i statuslinja (PWA)
- Lokalisert til en/nb/nn

Personvern og sikkerheit:
- Lagra GPS-posisjon utløper etter 24 timar (widget_prefs)
- Widget viser «Trykk for å oppdatere» når posisjon manglar eller er utløpt
- Eigendefinert User-Agent (Tilfluktsrom/1.6.1) i OkHttp
- Content Security Policy (CSP) meta-tag i PWA
- Tenararbeidar bufrar berre HTTP 200-svar (ikkje opake)
- Kartbuffer-metadata runda til ~11km presisjon i localStorage
- crossorigin="anonymous" på Leaflet CSS

i18n-opprydding:
- Unicode-escapes erstatta med UTF-8-teikn i nb.ts og nn.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-23 14:27:45 +01:00
commit c1ac68e746
21 changed files with 469 additions and 34 deletions

View file

@ -211,9 +211,12 @@ class ShelterWidgetProvider : AppWidgetProvider() {
return getSavedLocation(context)
}
/** Returns null if older than 24 hours to avoid retaining stale location data. */
private fun getSavedLocation(context: Context): Location? {
val prefs = context.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE)
if (!prefs.contains("last_lat")) return null
val age = System.currentTimeMillis() - prefs.getLong("last_time", 0L)
if (age > 24 * 60 * 60 * 1000L) return null
return Location("saved").apply {
latitude = prefs.getFloat("last_lat", 0f).toDouble()
longitude = prefs.getFloat("last_lon", 0f).toDouble()

View file

@ -69,9 +69,12 @@ class WidgetUpdateWorker(
return Result.success()
}
/** Returns null if older than 24 hours. */
private fun getSavedLocation(): Location? {
val prefs = applicationContext.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE)
if (!prefs.contains("last_lat")) return null
val age = System.currentTimeMillis() - prefs.getLong("last_time", 0L)
if (age > 24 * 60 * 60 * 1000L) return null
return Location("saved").apply {
latitude = prefs.getFloat("last_lat", 0f).toDouble()
longitude = prefs.getFloat("last_lon", 0f).toDouble()

View file

@ -7,6 +7,7 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
@ -43,6 +44,11 @@ class ShelterRepository(private val context: Context) {
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.addInterceptor(Interceptor { chain ->
chain.proceed(chain.request().newBuilder()
.header("User-Agent", "Tilfluktsrom/1.6.1")
.build())
})
.build()
/** Reactive stream of all shelters from local cache. */

View file

@ -0,0 +1,43 @@
package no.naiv.tilfluktsrom.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import androidx.fragment.app.DialogFragment
import no.naiv.tilfluktsrom.R
/**
* Full-screen dialog showing app info, privacy statement, and copyright.
* Static content all text comes from string resources for offline use and i18n.
*/
class AboutDialog : DialogFragment() {
companion object {
const val TAG = "AboutDialog"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_TITLE, R.style.Theme_Tilfluktsrom_Dialog)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return inflater.inflate(R.layout.dialog_about, container, false)
}
override fun onStart() {
super.onStart()
dialog?.window?.apply {
setLayout(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.WRAP_CONTENT
)
}
}
}

View file

@ -1,6 +1,5 @@
package no.naiv.tilfluktsrom.ui
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@ -32,6 +31,13 @@ class CivilDefenseInfoDialog : DialogFragment() {
return inflater.inflate(R.layout.dialog_civil_defense, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
view.findViewById<View>(R.id.aboutLink)?.setOnClickListener {
AboutDialog().show(parentFragmentManager, AboutDialog.TAG)
}
}
override fun onStart() {
super.onStart()
dialog?.window?.apply {

View file

@ -0,0 +1,104 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/status_bar_bg"
android:padding="20dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- Title -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="@string/about_title"
android:textColor="@color/shelter_primary"
android:textSize="20sp"
android:textStyle="bold" />
<!-- App description -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="@string/about_description"
android:textColor="@color/text_secondary"
android:textSize="14sp" />
<!-- Privacy section -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:text="@string/about_privacy_title"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="@string/about_privacy_body"
android:textColor="@color/text_secondary"
android:textSize="14sp" />
<!-- Data sources section -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:text="@string/about_data_title"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="@string/about_data_body"
android:textColor="@color/text_secondary"
android:textSize="14sp" />
<!-- Stored on device section -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:text="@string/about_stored_title"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:text="@string/about_stored_body"
android:textColor="@color/text_secondary"
android:textSize="14sp" />
<!-- Copyright -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_copyright"
android:textColor="@color/text_secondary"
android:textSize="11sp" />
<!-- Open source -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/about_open_source"
android:textColor="@color/text_secondary"
android:textSize="11sp" />
</LinearLayout>
</ScrollView>

View file

@ -114,14 +114,16 @@
android:textSize="12sp"
android:textStyle="italic" />
<!-- Copyright notice -->
<!-- About link -->
<TextView
android:id="@+id/aboutLink"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/app_copyright"
android:textColor="@color/text_secondary"
android:textSize="11sp" />
android:layout_height="48dp"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:text="@string/action_about"
android:textColor="@color/shelter_primary"
android:textSize="14sp" />
</LinearLayout>
</ScrollView>

View file

@ -85,6 +85,18 @@
<string name="civil_defense_step5_body">Én sammenhengende tone på omtrent 30 sekunder. Faren eller angrepet er over. Fortsett å følge instruksjoner fra myndighetene.</string>
<string name="civil_defense_source">Kilde: DSB (Direktoratet for samfunnssikkerhet og beredskap)</string>
<!-- Om -->
<string name="about_title">Om Tilfluktsrom</string>
<string name="about_description">Tilfluktsrom hjelper deg med å finne nærmeste offentlige tilfluktsrom i Norge. Appen er laget for å fungere uten internett etter første oppsett — du trenger ikke nett for å finne tilfluktsrom, navigere med kompass eller dele posisjonen din.</string>
<string name="about_privacy_title">Personvern</string>
<string name="about_privacy_body">Denne appen samler ikke inn, sender eller deler noen personopplysninger. Det finnes ingen analyse, sporing eller tredjepartstjenester.\n\nGPS-posisjonen din brukes bare lokalt på enheten din for å finne tilfluktsrom i nærheten, og sendes aldri til noen server.</string>
<string name="about_data_title">Datakilder</string>
<string name="about_data_body">Tilfluktsromdata er offentlig informasjon fra Geonorge (Kartverket). Kartfliser lastes fra OpenStreetMap. Begge lagres lokalt for frakoblet bruk.</string>
<string name="about_stored_title">Lagret på enheten din</string>
<string name="about_stored_body">• Tilfluktsromdatabase (offentlige data fra Geonorge)\n• Kartfliser for frakoblet bruk\n• Din siste GPS-posisjon (for hjemmeskjerm-widgeten)\n\nIngen data forlater enheten din bortsett fra forespørsler om å laste ned tilfluktsromdata og kartfliser.</string>
<string name="about_open_source">Åpen kildekode — kode.naiv.no/olemd/tilfluktsrom</string>
<string name="action_about">Om denne appen</string>
<!-- Opphavsrett -->
<string name="app_copyright">Opphavsrett © Ole-Morten Duesund</string>
</resources>

View file

@ -85,6 +85,18 @@
<string name="civil_defense_step5_body">Éin samanhengande tone på omtrent 30 sekund. Faren eller åtaket er over. Hald fram med å følgje instruksjonar frå styresmaktene.</string>
<string name="civil_defense_source">Kjelde: DSB (Direktoratet for samfunnstryggleik og beredskap)</string>
<!-- Om -->
<string name="about_title">Om Tilfluktsrom</string>
<string name="about_description">Tilfluktsrom hjelper deg med å finne næraste offentlege tilfluktsrom i Noreg. Appen er laga for å fungere utan internett etter fyrste oppsett — du treng ikkje nett for å finne tilfluktsrom, navigere med kompass eller dele posisjonen din.</string>
<string name="about_privacy_title">Personvern</string>
<string name="about_privacy_body">Denne appen samlar ikkje inn, sender eller deler nokon personopplysingar. Det finst ingen analyse, sporing eller tredjepartstenester.\n\nGPS-posisjonen din vert berre brukt lokalt på eininga di for å finne tilfluktsrom i nærleiken, og vert aldri sendt til nokon tenar.</string>
<string name="about_data_title">Datakjelder</string>
<string name="about_data_body">Tilfluktsromdata er offentleg informasjon frå Geonorge (Kartverket). Kartfliser vert lasta frå OpenStreetMap. Begge vert lagra lokalt for fråkopla bruk.</string>
<string name="about_stored_title">Lagra på eininga di</string>
<string name="about_stored_body">• Tilfluktsromdatabase (offentlege data frå Geonorge)\n• Kartfliser for fråkopla bruk\n• Din siste GPS-posisjon (for heimeskjerm-widgeten)\n\nIngen data forlèt eininga di bortsett frå førespurnader om å laste ned tilfluktsromdata og kartfliser.</string>
<string name="about_open_source">Open kjeldekode — kode.naiv.no/olemd/tilfluktsrom</string>
<string name="action_about">Om denne appen</string>
<!-- Opphavsrett -->
<string name="app_copyright">Opphavsrett © Ole-Morten Duesund</string>
</resources>

View file

@ -85,6 +85,18 @@
<string name="civil_defense_step5_body">One continuous tone lasting approximately 30 seconds. The danger or attack is over. Continue to follow instructions from authorities.</string>
<string name="civil_defense_source">Source: DSB (Norwegian Directorate for Civil Protection)</string>
<!-- About -->
<string name="about_title">About Tilfluktsrom</string>
<string name="about_description">Tilfluktsrom helps you find the nearest public shelter in Norway. The app is designed to work offline after initial setup — no internet required to find shelters, navigate by compass, or share your location.</string>
<string name="about_privacy_title">Privacy</string>
<string name="about_privacy_body">This app does not collect, transmit, or share any personal data. There are no analytics, tracking, or third-party services.\n\nYour GPS location is used only on your device to find nearby shelters and is never sent to any server.</string>
<string name="about_data_title">Data sources</string>
<string name="about_data_body">Shelter data is public information from Geonorge (Norwegian Mapping Authority). Map tiles are loaded from OpenStreetMap. Both are cached locally for offline use.</string>
<string name="about_stored_title">Stored on your device</string>
<string name="about_stored_body">• Shelter database (public data from Geonorge)\n• Map tiles for offline use\n• Your last GPS position (for the home screen widget)\n\nNo data leaves your device except requests to download shelter data and map tiles.</string>
<string name="about_open_source">Open source — kode.naiv.no/olemd/tilfluktsrom</string>
<string name="action_about">About this app</string>
<!-- Copyright -->
<string name="app_copyright">Copyright © Ole-Morten Duesund</string>
</resources>

View file

@ -252,10 +252,13 @@ class ShelterWidgetProvider : AppWidgetProvider() {
return getSavedLocation(context)
}
/** Read the last GPS fix persisted by MainActivity to SharedPreferences. */
/** Read the last GPS fix persisted by MainActivity to SharedPreferences.
* Returns null if older than 24 hours to avoid retaining stale location data. */
private fun getSavedLocation(context: Context): Location? {
val prefs = context.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE)
if (!prefs.contains("last_lat")) return null
val age = System.currentTimeMillis() - prefs.getLong("last_time", 0L)
if (age > 24 * 60 * 60 * 1000L) return null
return Location("saved").apply {
latitude = prefs.getFloat("last_lat", 0f).toDouble()
longitude = prefs.getFloat("last_lon", 0f).toDouble()

View file

@ -84,10 +84,13 @@ class WidgetUpdateWorker(
return Result.success()
}
/** Read the last GPS fix persisted by MainActivity. */
/** Read the last GPS fix persisted by MainActivity.
* Returns null if older than 24 hours. */
private fun getSavedLocation(): Location? {
val prefs = applicationContext.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE)
if (!prefs.contains("last_lat")) return null
val age = System.currentTimeMillis() - prefs.getLong("last_time", 0L)
if (age > 24 * 60 * 60 * 1000L) return null
return Location("saved").apply {
latitude = prefs.getFloat("last_lat", 0f).toDouble()
longitude = prefs.getFloat("last_lon", 0f).toDouble()