Legg til hybrid lokasjon, dataferskheit, widget og personvern (v1.2.0)

- Hybrid LocationProvider: prøver Play Services først, faller tilbake
  til LocationManager for degooglede einingar (F-Droid-kompatibel)
- Dataferskheitsindikator i statuslinja med tre nivå (fersk/veke/gammal)
- Heimeskjerm-widget som viser næraste tilfluktsrom med avstand
- Personvernerklæring (PRIVACY.md) på engelsk og norsk

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-08 19:10:57 +01:00
commit 57a9072b4c
13 changed files with 561 additions and 65 deletions

67
PRIVACY.md Normal file
View file

@ -0,0 +1,67 @@
# Privacy Policy / Personvernerklæring
**Tilfluktsrom — Norwegian Emergency Shelter Finder**
*Last updated: 2026-03-08*
---
## English
Tilfluktsrom is an open-source emergency shelter finder app. **We do not collect, store, or transmit any personal data.**
### What the app does with your data
- **Location**: Your GPS location is used **only on-device** to calculate the distance and direction to the nearest shelter. Your location is never sent to any server.
- **Shelter data**: Downloaded from [Geonorge](https://www.geonorge.no/) (a Norwegian government geographic data service). No user-identifying information is included in these requests.
- **Map tiles**: Fetched from OpenStreetMap via standard HTTP requests. No tracking or user identification is performed.
### What the app does NOT do
- No analytics or telemetry
- No advertising
- No cookies or local tracking
- No user accounts or registration
- No third-party SDKs that collect data
- No data sharing with any third party
### Permissions
- **Location**: Required to find the nearest shelter. Used only on-device.
- **Internet**: Required to download shelter data and map tiles. No personal data is transmitted.
- **Storage** (Android 89 only): Used for offline map tile cache.
### Contact
For questions about this privacy policy, open an issue at the project repository or contact the developer.
---
## Norsk
Tilfluktsrom er en åpen kildekode-app for å finne offentlige tilfluktsrom. **Vi samler ikke inn, lagrer eller overfører noen personopplysninger.**
### Hva appen gjør med dine data
- **Posisjon**: GPS-posisjonen din brukes **kun lokalt på enheten** for å beregne avstand og retning til nærmeste tilfluktsrom. Posisjonen din sendes aldri til noen server.
- **Tilfluktsromdata**: Lastes ned fra [Geonorge](https://www.geonorge.no/) (en norsk offentlig geografisk datatjeneste). Ingen brukeridentifiserende informasjon sendes.
- **Kartfliser**: Hentes fra OpenStreetMap via standard HTTP-forespørsler. Ingen sporing eller brukeridentifikasjon utføres.
### Hva appen IKKE gjør
- Ingen analyse eller telemetri
- Ingen reklame
- Ingen informasjonskapsler eller lokal sporing
- Ingen brukerkontoer eller registrering
- Ingen tredjeparts-SDK-er som samler data
- Ingen deling av data med tredjeparter
### Tillatelser
- **Posisjon**: Nødvendig for å finne nærmeste tilfluktsrom. Brukes kun lokalt på enheten.
- **Internett**: Nødvendig for å laste ned tilfluktsromdata og kartfliser. Ingen personopplysninger overføres.
- **Lagring** (kun Android 89): Brukes for frakoblet kartflislager.
### Kontakt
For spørsmål om denne personvernerklæringen, opprett en sak i prosjektets repository eller kontakt utvikleren.

View file

@ -29,5 +29,18 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver
android:name=".widget.ShelterWidgetProvider"
android:exported="true"
android:label="@string/nearest_shelter">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="no.naiv.tilfluktsrom.widget.REFRESH" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_info" />
</receiver>
</application>
</manifest>

View file

@ -17,6 +17,7 @@ import android.provider.Settings
import android.util.Log
import android.view.View
import android.widget.Toast
import java.util.concurrent.TimeUnit
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
@ -237,6 +238,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
repository.getAllShelters().collectLatest { shelters ->
allShelters = shelters
binding.statusText.text = getString(R.string.status_shelters_loaded, shelters.size)
updateFreshnessIndicator()
updateShelterMarkers()
currentLocation?.let { updateNearestShelters(it) }
}
@ -588,6 +590,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
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()
@ -595,6 +598,28 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
}
}
/** Update the freshness indicator below the status bar with color-coded age. */
private fun updateFreshnessIndicator() {
val lastUpdate = repository.getLastUpdateMs()
if (lastUpdate == 0L) {
binding.dataFreshnessText.visibility = View.GONE
return
}
val daysSince = TimeUnit.MILLISECONDS.toDays(
System.currentTimeMillis() - lastUpdate
).toInt()
val (textRes, colorRes) = when {
daysSince == 0 -> R.string.freshness_fresh to R.color.text_secondary
daysSince <= 7 -> R.string.freshness_week to R.color.shelter_accent
else -> R.string.freshness_old to R.color.shelter_primary
}
binding.dataFreshnessText.text = getString(textRes, daysSince)
binding.dataFreshnessText.setTextColor(ContextCompat.getColor(this, colorRes))
binding.dataFreshnessText.visibility = View.VISIBLE
}
private fun showLoading(message: String) {
binding.loadingOverlay.visibility = View.VISIBLE
binding.loadingText.text = message

View file

@ -51,6 +51,9 @@ class ShelterRepository(private val context: Context) {
/** Check if we have cached shelter data. */
suspend fun hasCachedData(): Boolean = dao.count() > 0
/** Timestamp (epoch ms) of the last successful data update, or 0 if never updated. */
fun getLastUpdateMs(): Long = prefs.getLong(KEY_LAST_UPDATE, 0)
/** Check if the cached data is stale and should be refreshed. */
fun isDataStale(): Boolean {
val lastUpdate = prefs.getLong(KEY_LAST_UPDATE, 0)

View file

@ -4,9 +4,14 @@ import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Bundle
import android.os.Looper
import android.util.Log
import androidx.core.content.ContextCompat
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
@ -18,11 +23,12 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
/**
* Provides GPS location updates using the Fused Location Provider.
* Emits location updates as a Flow for reactive consumption.
* Provides GPS location updates with automatic fallback.
*
* Closes the Flow with a SecurityException if location permission is not granted,
* so callers can detect and handle this failure explicitly.
* Uses FusedLocationProviderClient when Google Play Services is available (most devices),
* and falls back to LocationManager (GPS + Network providers) for degoogled/F-Droid devices.
*
* The public API is identical regardless of backend: locationUpdates() emits a Flow<Location>.
*/
class LocationProvider(private val context: Context) {
@ -32,8 +38,12 @@ class LocationProvider(private val context: Context) {
private const val FASTEST_INTERVAL_MS = 2000L
}
private val fusedClient: FusedLocationProviderClient =
LocationServices.getFusedLocationProviderClient(context)
/** Checked once at construction — Play Services availability won't change at runtime. */
private val usePlayServices: Boolean = isPlayServicesAvailable()
init {
Log.d(TAG, "Location backend: ${if (usePlayServices) "Play Services" else "LocationManager"}")
}
/**
* Stream of location updates. Emits the last known location first (if available),
@ -45,7 +55,11 @@ class LocationProvider(private val context: Context) {
return@callbackFlow
}
// Try to get last known location for immediate display
if (usePlayServices) {
val fusedClient: FusedLocationProviderClient =
LocationServices.getFusedLocationProviderClient(context)
// Emit last known location immediately for fast first display
try {
fusedClient.lastLocation
.addOnSuccessListener { location ->
@ -97,6 +111,77 @@ class LocationProvider(private val context: Context) {
awaitClose {
fusedClient.removeLocationUpdates(callback)
}
} else {
// Fallback: LocationManager for devices without Play Services
val locationManager = context.getSystemService(Context.LOCATION_SERVICE)
as? LocationManager
if (locationManager == null) {
close(IllegalStateException("LocationManager not available"))
return@callbackFlow
}
// Emit best last known location immediately (pick most recent of GPS/Network)
try {
val lastGps = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
val lastNetwork = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)
val best = listOfNotNull(lastGps, lastNetwork).maxByOrNull { it.time }
if (best != null) {
val result = trySend(best)
if (result.isFailure) {
Log.w(TAG, "Failed to emit last known location (fallback)")
}
}
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException getting last known location (fallback)", e)
}
// LocationListener compatible with API 26-28 (onStatusChanged required before API 29)
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
val sendResult = trySend(location)
if (sendResult.isFailure) {
Log.w(TAG, "Failed to emit location update (fallback)")
}
}
// Required for API 26-28 compatibility (deprecated from API 29+)
@Deprecated("Deprecated in API 29")
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}
override fun onProviderEnabled(provider: String) {}
override fun onProviderDisabled(provider: String) {}
}
try {
// Request from both providers: GPS is accurate, Network gives faster first fix
if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER,
UPDATE_INTERVAL_MS,
0f,
listener,
Looper.getMainLooper()
)
}
if (locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) {
locationManager.requestLocationUpdates(
LocationManager.NETWORK_PROVIDER,
UPDATE_INTERVAL_MS,
0f,
listener,
Looper.getMainLooper()
)
}
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException requesting location updates (fallback)", e)
close(e)
return@callbackFlow
}
awaitClose {
locationManager.removeUpdates(listener)
}
}
}
fun hasLocationPermission(): Boolean {
@ -104,4 +189,15 @@ class LocationProvider(private val context: Context) {
context, Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
}
private fun isPlayServicesAvailable(): Boolean {
return try {
val result = GoogleApiAvailability.getInstance()
.isGooglePlayServicesAvailable(context)
result == ConnectionResult.SUCCESS
} catch (e: Exception) {
// Play Services library might not even be resolvable on some ROMs
false
}
}
}

View file

@ -0,0 +1,167 @@
package no.naiv.tilfluktsrom.widget
import android.Manifest
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationManager
import android.util.Log
import android.widget.RemoteViews
import androidx.core.content.ContextCompat
import kotlinx.coroutines.runBlocking
import no.naiv.tilfluktsrom.MainActivity
import no.naiv.tilfluktsrom.R
import no.naiv.tilfluktsrom.data.ShelterDatabase
import no.naiv.tilfluktsrom.location.ShelterFinder
import no.naiv.tilfluktsrom.util.DistanceUtils
/**
* Home screen widget showing the nearest shelter with distance.
*
* Update strategy: no automatic periodic updates (updatePeriodMillis=0).
* Updates only when the user taps the refresh button, which sends ACTION_REFRESH.
* Tapping the widget body opens MainActivity.
*
* Uses LocationManager directly (not the hybrid LocationProvider) because
* BroadcastReceiver context makes FusedLocationProviderClient setup awkward.
* For a one-shot getLastKnownLocation, LocationManager is equally effective.
*/
class ShelterWidgetProvider : AppWidgetProvider() {
companion object {
private const val TAG = "ShelterWidget"
const val ACTION_REFRESH = "no.naiv.tilfluktsrom.widget.REFRESH"
}
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
for (appWidgetId in appWidgetIds) {
updateWidget(context, appWidgetManager, appWidgetId)
}
}
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
if (intent.action == ACTION_REFRESH) {
val appWidgetManager = AppWidgetManager.getInstance(context)
val widgetIds = appWidgetManager.getAppWidgetIds(
ComponentName(context, ShelterWidgetProvider::class.java)
)
for (appWidgetId in widgetIds) {
updateWidget(context, appWidgetManager, appWidgetId)
}
}
}
private fun updateWidget(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int
) {
val views = RemoteViews(context.packageName, R.layout.widget_nearest_shelter)
// Tapping widget body opens the app
val openAppIntent = Intent(context, MainActivity::class.java)
val openAppPending = PendingIntent.getActivity(
context, 0, openAppIntent, PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.widgetRoot, openAppPending)
// Refresh button sends our custom broadcast
val refreshIntent = Intent(context, ShelterWidgetProvider::class.java).apply {
action = ACTION_REFRESH
}
val refreshPending = PendingIntent.getBroadcast(
context, 0, refreshIntent, PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.widgetRefreshButton, refreshPending)
// Check location permission
if (ContextCompat.checkSelfPermission(
context, Manifest.permission.ACCESS_FINE_LOCATION
) != PackageManager.PERMISSION_GRANTED
) {
showFallback(views, context.getString(R.string.widget_open_app))
appWidgetManager.updateAppWidget(appWidgetId, views)
return
}
// Get last known location from LocationManager
val location = getLastKnownLocation(context)
if (location == null) {
showFallback(views, context.getString(R.string.widget_no_location))
appWidgetManager.updateAppWidget(appWidgetId, views)
return
}
// Query shelters from Room (fast: ~556 rows, <10ms)
val shelters = try {
val dao = ShelterDatabase.getInstance(context).shelterDao()
runBlocking { dao.getAllSheltersList() }
} catch (e: Exception) {
Log.e(TAG, "Failed to query shelters", e)
emptyList()
}
if (shelters.isEmpty()) {
showFallback(views, context.getString(R.string.widget_no_data))
appWidgetManager.updateAppWidget(appWidgetId, views)
return
}
// Find nearest shelter
val nearest = ShelterFinder.findNearest(
shelters, location.latitude, location.longitude, 1
).firstOrNull()
if (nearest == null) {
showFallback(views, context.getString(R.string.widget_no_data))
appWidgetManager.updateAppWidget(appWidgetId, views)
return
}
// Show shelter info
views.setTextViewText(R.id.widgetAddress, nearest.shelter.adresse)
views.setTextViewText(
R.id.widgetDetails,
context.getString(R.string.shelter_capacity, nearest.shelter.plasser)
)
views.setTextViewText(
R.id.widgetDistance,
DistanceUtils.formatDistance(nearest.distanceMeters)
)
appWidgetManager.updateAppWidget(appWidgetId, views)
}
/** Show a fallback message when location or data is unavailable. */
private fun showFallback(views: RemoteViews, message: String) {
views.setTextViewText(R.id.widgetAddress, message)
views.setTextViewText(R.id.widgetDetails, "")
views.setTextViewText(R.id.widgetDistance, "")
}
/** Get the best last known location from GPS and Network providers. */
private fun getLastKnownLocation(context: Context): Location? {
val locationManager = context.getSystemService(Context.LOCATION_SERVICE)
as? LocationManager ?: return null
return try {
val lastGps = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
val lastNetwork = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)
listOfNotNull(lastGps, lastNetwork).maxByOrNull { it.time }
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException getting last known location", e)
null
}
}
}

View file

@ -15,10 +15,16 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/status_bar_bg"
android:orientation="vertical"
app:layout_constraintTop_toTopOf="parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="8dp"
app:layout_constraintTop_toTopOf="parent">
android:paddingHorizontal="8dp"
android:paddingVertical="4dp">
<TextView
android:id="@+id/statusText"
@ -39,6 +45,18 @@
app:tint="@color/status_text" />
</LinearLayout>
<TextView
android:id="@+id/dataFreshnessText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="8dp"
android:paddingBottom="4dp"
android:textSize="11sp"
android:visibility="gone"
tools:text="Data is up to date"
tools:visibility="visible" />
</LinearLayout>
<!-- Map view (main content) -->
<org.osmdroid.views.MapView
android:id="@+id/mapView"

View file

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widgetRoot"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="8dp">
<ImageView
android:id="@+id/widgetIcon"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/nearest_shelter"
android:src="@drawable/ic_shelter" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/widgetAddress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:textStyle="bold"
tools:text="Storgata 1" />
<TextView
android:id="@+id/widgetDetails"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/text_secondary"
android:textSize="12sp"
tools:text="400 places" />
</LinearLayout>
<TextView
android:id="@+id/widgetDistance"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:textColor="@color/shelter_primary"
android:textSize="18sp"
android:textStyle="bold"
tools:text="1.2 km" />
<ImageView
android:id="@+id/widgetRefreshButton"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginStart="4dp"
android:contentDescription="@string/action_refresh"
android:src="@drawable/ic_refresh" />
</LinearLayout>

View file

@ -44,6 +44,17 @@
<string name="update_success">Tilfluktsromdata oppdatert</string>
<string name="update_failed">Oppdatering mislyktes — bruker lagrede data</string>
<!-- Widget -->
<string name="widget_description">Viser n\u00e6rmeste tilfluktsrom med avstand</string>
<string name="widget_open_app">\u00c5pne appen for posisjon</string>
<string name="widget_no_data">Ingen tilfluktsromdata</string>
<string name="widget_no_location">Trykk for \u00e5 oppdatere</string>
<!-- Dataferskhet -->
<string name="freshness_fresh">Data er oppdatert</string>
<string name="freshness_week">Data er %d dager gammel</string>
<string name="freshness_old">Data er utdatert</string>
<!-- Tilgjengelighet -->
<string name="direction_arrow_description">Retning til tilfluktsrom, %s unna</string>
</resources>

View file

@ -44,6 +44,17 @@
<string name="update_success">Tilfluktsromdata oppdatert</string>
<string name="update_failed">Oppdatering mislukkast — brukar lagra data</string>
<!-- Widget -->
<string name="widget_description">Viser n\u00e6raste tilfluktsrom med avstand</string>
<string name="widget_open_app">Opne appen for posisjon</string>
<string name="widget_no_data">Ingen tilfluktsromdata</string>
<string name="widget_no_location">Trykk for \u00e5 oppdatere</string>
<!-- Dataferskheit -->
<string name="freshness_fresh">Data er oppdatert</string>
<string name="freshness_week">Data er %d dagar gammal</string>
<string name="freshness_old">Data er utdatert</string>
<!-- Tilgjenge -->
<string name="direction_arrow_description">Retning til tilfluktsrom, %s unna</string>
</resources>

View file

@ -44,6 +44,17 @@
<string name="update_success">Shelter data updated</string>
<string name="update_failed">Update failed — using cached data</string>
<!-- Widget -->
<string name="widget_description">Shows nearest shelter with distance</string>
<string name="widget_open_app">Open app for location</string>
<string name="widget_no_data">No shelter data</string>
<string name="widget_no_location">Tap to refresh</string>
<!-- Data freshness -->
<string name="freshness_fresh">Data is up to date</string>
<string name="freshness_week">Data is %d days old</string>
<string name="freshness_old">Data is outdated</string>
<!-- Accessibility -->
<string name="direction_arrow_description">Direction to shelter, %s away</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="40dp"
android:updatePeriodMillis="0"
android:initialLayout="@layout/widget_nearest_shelter"
android:resizeMode="horizontal"
android:widgetCategory="home_screen"
android:description="@string/widget_description" />

View file

@ -1,4 +1,4 @@
versionMajor=1
versionMinor=1
versionMinor=2
versionPatch=0
versionCode=2
versionCode=3