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:
parent
27801f1dfc
commit
57a9072b4c
13 changed files with 561 additions and 65 deletions
67
PRIVACY.md
Normal file
67
PRIVACY.md
Normal 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 8–9 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 8–9): Brukes for frakoblet kartflislager.
|
||||||
|
|
||||||
|
### Kontakt
|
||||||
|
|
||||||
|
For spørsmål om denne personvernerklæringen, opprett en sak i prosjektets repository eller kontakt utvikleren.
|
||||||
|
|
@ -29,5 +29,18 @@
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import android.provider.Settings
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
|
@ -237,6 +238,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
||||||
repository.getAllShelters().collectLatest { shelters ->
|
repository.getAllShelters().collectLatest { shelters ->
|
||||||
allShelters = shelters
|
allShelters = shelters
|
||||||
binding.statusText.text = getString(R.string.status_shelters_loaded, shelters.size)
|
binding.statusText.text = getString(R.string.status_shelters_loaded, shelters.size)
|
||||||
|
updateFreshnessIndicator()
|
||||||
updateShelterMarkers()
|
updateShelterMarkers()
|
||||||
currentLocation?.let { updateNearestShelters(it) }
|
currentLocation?.let { updateNearestShelters(it) }
|
||||||
}
|
}
|
||||||
|
|
@ -588,6 +590,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
||||||
binding.statusText.text = getString(R.string.status_updating)
|
binding.statusText.text = getString(R.string.status_updating)
|
||||||
val success = repository.refreshData()
|
val success = repository.refreshData()
|
||||||
if (success) {
|
if (success) {
|
||||||
|
updateFreshnessIndicator()
|
||||||
Toast.makeText(this@MainActivity, R.string.update_success, Toast.LENGTH_SHORT).show()
|
Toast.makeText(this@MainActivity, R.string.update_success, Toast.LENGTH_SHORT).show()
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(this@MainActivity, R.string.update_failed, Toast.LENGTH_SHORT).show()
|
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) {
|
private fun showLoading(message: String) {
|
||||||
binding.loadingOverlay.visibility = View.VISIBLE
|
binding.loadingOverlay.visibility = View.VISIBLE
|
||||||
binding.loadingText.text = message
|
binding.loadingText.text = message
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,9 @@ class ShelterRepository(private val context: Context) {
|
||||||
/** Check if we have cached shelter data. */
|
/** Check if we have cached shelter data. */
|
||||||
suspend fun hasCachedData(): Boolean = dao.count() > 0
|
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. */
|
/** Check if the cached data is stale and should be refreshed. */
|
||||||
fun isDataStale(): Boolean {
|
fun isDataStale(): Boolean {
|
||||||
val lastUpdate = prefs.getLong(KEY_LAST_UPDATE, 0)
|
val lastUpdate = prefs.getLong(KEY_LAST_UPDATE, 0)
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,14 @@ import android.Manifest
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.location.Location
|
import android.location.Location
|
||||||
|
import android.location.LocationListener
|
||||||
|
import android.location.LocationManager
|
||||||
|
import android.os.Bundle
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.content.ContextCompat
|
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.FusedLocationProviderClient
|
||||||
import com.google.android.gms.location.LocationCallback
|
import com.google.android.gms.location.LocationCallback
|
||||||
import com.google.android.gms.location.LocationRequest
|
import com.google.android.gms.location.LocationRequest
|
||||||
|
|
@ -18,11 +23,12 @@ import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides GPS location updates using the Fused Location Provider.
|
* Provides GPS location updates with automatic fallback.
|
||||||
* Emits location updates as a Flow for reactive consumption.
|
|
||||||
*
|
*
|
||||||
* Closes the Flow with a SecurityException if location permission is not granted,
|
* Uses FusedLocationProviderClient when Google Play Services is available (most devices),
|
||||||
* so callers can detect and handle this failure explicitly.
|
* 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) {
|
class LocationProvider(private val context: Context) {
|
||||||
|
|
||||||
|
|
@ -32,8 +38,12 @@ class LocationProvider(private val context: Context) {
|
||||||
private const val FASTEST_INTERVAL_MS = 2000L
|
private const val FASTEST_INTERVAL_MS = 2000L
|
||||||
}
|
}
|
||||||
|
|
||||||
private val fusedClient: FusedLocationProviderClient =
|
/** Checked once at construction — Play Services availability won't change at runtime. */
|
||||||
LocationServices.getFusedLocationProviderClient(context)
|
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),
|
* Stream of location updates. Emits the last known location first (if available),
|
||||||
|
|
@ -45,57 +55,132 @@ class LocationProvider(private val context: Context) {
|
||||||
return@callbackFlow
|
return@callbackFlow
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to get last known location for immediate display
|
if (usePlayServices) {
|
||||||
try {
|
val fusedClient: FusedLocationProviderClient =
|
||||||
fusedClient.lastLocation
|
LocationServices.getFusedLocationProviderClient(context)
|
||||||
.addOnSuccessListener { location ->
|
|
||||||
if (location != null) {
|
// Emit last known location immediately for fast first display
|
||||||
val result = trySend(location)
|
try {
|
||||||
if (result.isFailure) {
|
fusedClient.lastLocation
|
||||||
Log.w(TAG, "Failed to emit last known location")
|
.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(
|
try {
|
||||||
Priority.PRIORITY_HIGH_ACCURACY,
|
fusedClient.requestLocationUpdates(
|
||||||
UPDATE_INTERVAL_MS
|
locationRequest,
|
||||||
).apply {
|
callback,
|
||||||
setMinUpdateIntervalMillis(FASTEST_INTERVAL_MS)
|
Looper.getMainLooper()
|
||||||
setWaitForAccurateLocation(false)
|
)
|
||||||
}.build()
|
} catch (e: SecurityException) {
|
||||||
|
Log.e(TAG, "SecurityException requesting location updates", e)
|
||||||
|
close(e)
|
||||||
|
return@callbackFlow
|
||||||
|
}
|
||||||
|
|
||||||
val callback = object : LocationCallback() {
|
awaitClose {
|
||||||
override fun onLocationResult(result: LocationResult) {
|
fusedClient.removeLocationUpdates(callback)
|
||||||
result.lastLocation?.let { location ->
|
}
|
||||||
val sendResult = trySend(location)
|
} else {
|
||||||
if (sendResult.isFailure) {
|
// Fallback: LocationManager for devices without Play Services
|
||||||
Log.w(TAG, "Failed to emit location update")
|
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 {
|
// LocationListener compatible with API 26-28 (onStatusChanged required before API 29)
|
||||||
fusedClient.requestLocationUpdates(
|
val listener = object : LocationListener {
|
||||||
locationRequest,
|
override fun onLocationChanged(location: Location) {
|
||||||
callback,
|
val sendResult = trySend(location)
|
||||||
Looper.getMainLooper()
|
if (sendResult.isFailure) {
|
||||||
)
|
Log.w(TAG, "Failed to emit location update (fallback)")
|
||||||
} catch (e: SecurityException) {
|
}
|
||||||
Log.e(TAG, "SecurityException requesting location updates", e)
|
}
|
||||||
close(e)
|
|
||||||
return@callbackFlow
|
|
||||||
}
|
|
||||||
|
|
||||||
awaitClose {
|
// Required for API 26-28 compatibility (deprecated from API 29+)
|
||||||
fusedClient.removeLocationUpdates(callback)
|
@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
|
context, Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
) == 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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,28 +15,46 @@
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:background="@color/status_bar_bg"
|
android:background="@color/status_bar_bg"
|
||||||
android:gravity="center_vertical"
|
android:orientation="vertical"
|
||||||
android:orientation="horizontal"
|
|
||||||
android:padding="8dp"
|
|
||||||
app:layout_constraintTop_toTopOf="parent">
|
app:layout_constraintTop_toTopOf="parent">
|
||||||
|
|
||||||
<TextView
|
<LinearLayout
|
||||||
android:id="@+id/statusText"
|
android:layout_width="match_parent"
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:gravity="center_vertical"
|
||||||
android:textColor="@color/status_text"
|
android:orientation="horizontal"
|
||||||
android:textSize="12sp"
|
android:paddingHorizontal="8dp"
|
||||||
tools:text="@string/status_ready" />
|
android:paddingVertical="4dp">
|
||||||
|
|
||||||
<ImageButton
|
<TextView
|
||||||
android:id="@+id/refreshButton"
|
android:id="@+id/statusText"
|
||||||
android:layout_width="36dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="36dp"
|
android:layout_height="wrap_content"
|
||||||
android:background="?attr/selectableItemBackgroundBorderless"
|
android:layout_weight="1"
|
||||||
android:contentDescription="@string/action_refresh"
|
android:textColor="@color/status_text"
|
||||||
android:src="@drawable/ic_refresh"
|
android:textSize="12sp"
|
||||||
app:tint="@color/status_text" />
|
tools:text="@string/status_ready" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/refreshButton"
|
||||||
|
android:layout_width="36dp"
|
||||||
|
android:layout_height="36dp"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:contentDescription="@string/action_refresh"
|
||||||
|
android:src="@drawable/ic_refresh"
|
||||||
|
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>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Map view (main content) -->
|
<!-- Map view (main content) -->
|
||||||
|
|
|
||||||
65
app/src/main/res/layout/widget_nearest_shelter.xml
Normal file
65
app/src/main/res/layout/widget_nearest_shelter.xml
Normal 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>
|
||||||
|
|
@ -44,6 +44,17 @@
|
||||||
<string name="update_success">Tilfluktsromdata oppdatert</string>
|
<string name="update_success">Tilfluktsromdata oppdatert</string>
|
||||||
<string name="update_failed">Oppdatering mislyktes — bruker lagrede data</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 -->
|
<!-- Tilgjengelighet -->
|
||||||
<string name="direction_arrow_description">Retning til tilfluktsrom, %s unna</string>
|
<string name="direction_arrow_description">Retning til tilfluktsrom, %s unna</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,17 @@
|
||||||
<string name="update_success">Tilfluktsromdata oppdatert</string>
|
<string name="update_success">Tilfluktsromdata oppdatert</string>
|
||||||
<string name="update_failed">Oppdatering mislukkast — brukar lagra data</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 -->
|
<!-- Tilgjenge -->
|
||||||
<string name="direction_arrow_description">Retning til tilfluktsrom, %s unna</string>
|
<string name="direction_arrow_description">Retning til tilfluktsrom, %s unna</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,17 @@
|
||||||
<string name="update_success">Shelter data updated</string>
|
<string name="update_success">Shelter data updated</string>
|
||||||
<string name="update_failed">Update failed — using cached data</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 -->
|
<!-- Accessibility -->
|
||||||
<string name="direction_arrow_description">Direction to shelter, %s away</string>
|
<string name="direction_arrow_description">Direction to shelter, %s away</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
9
app/src/main/res/xml/widget_info.xml
Normal file
9
app/src/main/res/xml/widget_info.xml
Normal 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" />
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
versionMajor=1
|
versionMajor=1
|
||||||
versionMinor=1
|
versionMinor=2
|
||||||
versionPatch=0
|
versionPatch=0
|
||||||
versionCode=2
|
versionCode=3
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue