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

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,57 +55,132 @@ class LocationProvider(private val context: Context) {
return@callbackFlow
}
// Try to get last known location for immediate display
try {
fusedClient.lastLocation
.addOnSuccessListener { location ->
if (location != null) {
val result = trySend(location)
if (result.isFailure) {
Log.w(TAG, "Failed to emit last known location")
if (usePlayServices) {
val fusedClient: FusedLocationProviderClient =
LocationServices.getFusedLocationProviderClient(context)
// Emit last known location immediately for fast first display
try {
fusedClient.lastLocation
.addOnSuccessListener { location ->
if (location != null) {
val result = trySend(location)
if (result.isFailure) {
Log.w(TAG, "Failed to emit last known location")
}
}
}
.addOnFailureListener { e ->
Log.w(TAG, "Could not get last known location", e)
}
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException getting last location", e)
}
val locationRequest = LocationRequest.Builder(
Priority.PRIORITY_HIGH_ACCURACY,
UPDATE_INTERVAL_MS
).apply {
setMinUpdateIntervalMillis(FASTEST_INTERVAL_MS)
setWaitForAccurateLocation(false)
}.build()
val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
result.lastLocation?.let { location ->
val sendResult = trySend(location)
if (sendResult.isFailure) {
Log.w(TAG, "Failed to emit location update")
}
}
}
.addOnFailureListener { e ->
Log.w(TAG, "Could not get last known location", e)
}
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException getting last location", e)
}
}
val locationRequest = LocationRequest.Builder(
Priority.PRIORITY_HIGH_ACCURACY,
UPDATE_INTERVAL_MS
).apply {
setMinUpdateIntervalMillis(FASTEST_INTERVAL_MS)
setWaitForAccurateLocation(false)
}.build()
try {
fusedClient.requestLocationUpdates(
locationRequest,
callback,
Looper.getMainLooper()
)
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException requesting location updates", e)
close(e)
return@callbackFlow
}
val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
result.lastLocation?.let { location ->
val sendResult = trySend(location)
if (sendResult.isFailure) {
Log.w(TAG, "Failed to emit location update")
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)
}
}
try {
fusedClient.requestLocationUpdates(
locationRequest,
callback,
Looper.getMainLooper()
)
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException requesting location updates", e)
close(e)
return@callbackFlow
}
// 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)")
}
}
awaitClose {
fusedClient.removeLocationUpdates(callback)
// 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)
}
}
}
@ -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
}
}
}