Compare commits

...

2 commits

Author SHA1 Message Date
7ce0827e9f Fjern utdatert notis om 24-timers GPS-utløp i README
Linjen under «Sikkerhet» beskrev 24-timers-grensen på
widget_prefs-cachen som ble fjernet sammen med widgeten i
forrige commit. Appen persisterer nå ikke GPS-posisjon
overhodet, så linjen er erstattet med «GPS-posisjon lagres
ikke — den brukes bare i minnet mens appen er i bruk», som
samsvarer med den allerede oppdaterte teksten i
about_stored_body.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 10:02:14 +02:00
0743eac9dd Fjern hjemmeskjerm-widget
Widgeten har vært en vedlikeholdskostnad uten et klart produktformål:
den duplikerte lokasjonslogikken fra hovedappen, kunne vise inntil
24 timer gammel GPS-posisjon uten alderindikator, og krevde en egen
WorkManager-periodisk oppdatering. Den strategiske vurderingen
(2026-04-17) konkluderte med at den samme nytten kan leveres via
app-åpning eller en lettere mekanisme senere, og at flaten bør
krympes før pitch mot offentlig sektor.

Denne endringen fjerner widget/-pakken for begge flavors
(standard + fdroid), AppWidgetProvider-mottakeren i manifestet,
WidgetUpdateWorker, androidx.work:work-runtime-ktx-avhengigheten,
widget_prefs SharedPreferences-lagringen i MainActivity, samt
widget_*-strenger og linjen om «for hjemmeskjerm-widgeten» i
about-dialogen. Dokumentasjonen i CLAUDE.md, ARCHITECTURE.md,
README.md, STANDING_ON_SHOULDERS.md og fastlane-beskrivelsene
er justert tilsvarende. Historiske changelogs (v3, v5, v6, v7)
er bevisst urørt — de beskriver korrekt hva som ble levert i
de versjonene.

Eksisterende widget-plasseringer på brukernes hjemmeskjerm
forsvinner automatisk neste gang appen oppdateres; Android
fjerner foreldreløse provider-komponenter uten migreringskode.
Begge debug-flavors bygger rent etter endringen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 09:57:45 +02:00
19 changed files with 10 additions and 1112 deletions

View file

@ -13,7 +13,6 @@ This document describes the architecture of Tilfluktsrom, a Norwegian emergency
- [Compass System](#compass-system) - [Compass System](#compass-system)
- [Map & Tile Caching](#map--tile-caching) - [Map & Tile Caching](#map--tile-caching)
- [Build Variants](#build-variants) - [Build Variants](#build-variants)
- [Home Screen Widget](#home-screen-widget)
- [Deep Linking](#deep-linking) - [Deep Linking](#deep-linking)
- [Progressive Web App](#progressive-web-app) - [Progressive Web App](#progressive-web-app)
- [Module Structure](#module-structure) - [Module Structure](#module-structure)
@ -41,7 +40,7 @@ This is an emergency app. Core functionality — finding the nearest shelter, co
The Android app runs on devices without Google Play Services (LineageOS, GrapheneOS, /e/OS). Every Google-specific API has an AOSP fallback. Play Services improve accuracy and battery life when available, but are never required. The Android app runs on devices without Google Play Services (LineageOS, GrapheneOS, /e/OS). Every Google-specific API has an AOSP fallback. Play Services improve accuracy and battery life when available, but are never required.
### Minimal Dependencies ### Minimal Dependencies
Both platforms use few, well-chosen libraries. No heavy frameworks, no external CDNs at runtime. The PWA bundles everything locally; the Android app uses only OSMDroid, Room, OkHttp, and WorkManager. Both platforms use few, well-chosen libraries. No heavy frameworks, no external CDNs at runtime. The PWA bundles everything locally; the Android app uses only OSMDroid, Room, and OkHttp.
### Data Sovereignty ### Data Sovereignty
Shelter data comes directly from Geonorge (the Norwegian mapping authority). No intermediate servers. The app fetches, converts, and caches the data locally. Shelter data comes directly from Geonorge (the Norwegian mapping authority). No intermediate servers. The app fetches, converts, and caches the data locally.
@ -107,15 +106,12 @@ no.naiv.tilfluktsrom/
│ ├── ShelterListAdapter.kt # RecyclerView adapter for shelter list │ ├── ShelterListAdapter.kt # RecyclerView adapter for shelter list
│ ├── CivilDefenseInfoDialog.kt # Emergency instructions │ ├── CivilDefenseInfoDialog.kt # Emergency instructions
│ └── AboutDialog.kt # Privacy and copyright │ └── AboutDialog.kt # Privacy and copyright
├── util/ └── util/
│ ├── CoordinateConverter.kt # UTM33N → WGS84 (Karney method) ├── CoordinateConverter.kt # UTM33N → WGS84 (Karney method)
│ └── DistanceUtils.kt # Haversine distance and bearing └── DistanceUtils.kt # Haversine distance and bearing
└── widget/
├── ShelterWidgetProvider.kt # Home screen widget (flavor-specific)
└── WidgetUpdateWorker.kt # WorkManager periodic update
``` ```
Files under `location/` and `widget/` have separate implementations per build variant: Files under `location/` have separate implementations per build variant:
- `app/src/standard/java/` — Google Play Services variant - `app/src/standard/java/` — Google Play Services variant
- `app/src/fdroid/java/` — AOSP-only variant - `app/src/fdroid/java/` — AOSP-only variant
@ -198,21 +194,6 @@ The `standard` flavor adds `com.google.android.gms:play-services-location`. Runt
Both flavors produce identical user experiences — `standard` achieves faster GPS fixes and better battery efficiency when Play Services are present. Both flavors produce identical user experiences — `standard` achieves faster GPS fixes and better battery efficiency when Play Services are present.
### Home Screen Widget
**ShelterWidgetProvider** displays the nearest shelter's address, capacity, and distance. Updated by:
1. **MainActivity** — sends latest location on each GPS update
2. **WorkManager**`WidgetUpdateWorker` runs every 15 minutes, requests a fresh location fix
3. **Manual** — user taps refresh button on the widget
**Location resolution (priority order):**
1. Location from intent (WorkManager or MainActivity)
2. FusedLocationProviderClient cache (standard)
3. Active GPS request (10s timeout)
4. LocationManager cache
5. SharedPreferences saved location (max 24h old)
### Deep Linking ### Deep Linking
**HTTPS App Links:** `https://tilfluktsrom.naiv.no/shelter/{lokalId}` **HTTPS App Links:** `https://tilfluktsrom.naiv.no/shelter/{lokalId}`

View file

@ -12,7 +12,6 @@ The app must work on devices without Google Play Services (e.g. LineageOS, Graph
- **Location**: Prefer FusedLocationProviderClient (Play Services) → fall back to LocationManager (AOSP) - **Location**: Prefer FusedLocationProviderClient (Play Services) → fall back to LocationManager (AOSP)
- **Maps**: OSMDroid (no Google dependency) - **Maps**: OSMDroid (no Google dependency)
- **Database**: Room/SQLite (no Google dependency) - **Database**: Room/SQLite (no Google dependency)
- **Background work**: WorkManager (works without Play Services via built-in scheduler)
### Offline-First ### Offline-First
This is an emergency app. Assume internet and infrastructure may be degraded or unavailable. All core functionality (finding nearest shelter, compass navigation, sharing location) must work offline after initial data cache. Avoid solutions that depend on external servers being reachable. This is an emergency app. Assume internet and infrastructure may be degraded or unavailable. All core functionality (finding nearest shelter, compass navigation, sharing location) must work offline after initial data cache. Avoid solutions that depend on external servers being reachable.
@ -24,7 +23,6 @@ This is an emergency app. Assume internet and infrastructure may be degraded or
- **Database**: Room (SQLite) for shelter data cache - **Database**: Room (SQLite) for shelter data cache
- **HTTP**: OkHttp for data downloads - **HTTP**: OkHttp for data downloads
- **Location**: FusedLocationProviderClient (Play Services) with LocationManager fallback - **Location**: FusedLocationProviderClient (Play Services) with LocationManager fallback
- **Background**: WorkManager for periodic widget updates
- **UI**: Traditional Views with ViewBinding - **UI**: Traditional Views with ViewBinding
## Key Data Flow ## Key Data Flow
@ -44,7 +42,6 @@ no.naiv.tilfluktsrom/
├── data/ # Room entities, DAO, repository, GeoJSON parser, map cache ├── data/ # Room entities, DAO, repository, GeoJSON parser, map cache
├── location/ # GPS location provider, nearest shelter finder ├── location/ # GPS location provider, nearest shelter finder
├── ui/ # Custom views (DirectionArrowView), adapters ├── ui/ # Custom views (DirectionArrowView), adapters
├── widget/ # Home screen widget, WorkManager periodic updater
└── util/ # Coordinate conversion (UTM→WGS84), distance calculations └── util/ # Coordinate conversion (UTM→WGS84), distance calculations
``` ```

View file

@ -28,7 +28,6 @@ Finn nærmeste offentlige tilfluktsrom i Norge. Appen er bygd for nødsituasjone
- **Velg fritt** — trykk på en markering i kartet for å navigere dit - **Velg fritt** — trykk på en markering i kartet for å navigere dit
- **Del tilfluktsrom** — send adresse, kapasitet og koordinater til andre - **Del tilfluktsrom** — send adresse, kapasitet og koordinater til andre
- **Sivilforsvarsinfo** — veiledning fra DSB om hva du skal gjøre når alarmen går - **Sivilforsvarsinfo** — veiledning fra DSB om hva du skal gjøre når alarmen går
- **Hjemmeskjerm-widget** — viser nærmeste tilfluktsrom uten å åpne appen
- **Flerspråklig** — engelsk, bokmål og nynorsk - **Flerspråklig** — engelsk, bokmål og nynorsk
- **Tilgjengelighet** — TalkBack-støtte, fokusindikatorer og tilstrekkelig kontrast - **Tilgjengelighet** — TalkBack-støtte, fokusindikatorer og tilstrekkelig kontrast
@ -70,7 +69,6 @@ tilfluktsrom/
│ │ ├── data/ # Room-database, nedlasting, GeoJSON-parser │ │ ├── data/ # Room-database, nedlasting, GeoJSON-parser
│ │ ├── location/ # GPS, nærmeste tilfluktsrom │ │ ├── location/ # GPS, nærmeste tilfluktsrom
│ │ ├── ui/ # Retningspil, liste-adapter, om-dialog │ │ ├── ui/ # Retningspil, liste-adapter, om-dialog
│ │ ├── widget/ # Hjemmeskjerm-widget
│ │ └── util/ # UTM→WGS84-konvertering, avstandsberegning │ │ └── util/ # UTM→WGS84-konvertering, avstandsberegning
│ └── res/ # Layout, strenger (en/nb/nn), ikoner │ └── res/ # Layout, strenger (en/nb/nn), ikoner
├── pwa/ # Nettapp (TypeScript) ├── pwa/ # Nettapp (TypeScript)
@ -99,7 +97,7 @@ Appen er designet etter «offline-first»-prinsippet:
- Content Security Policy (CSP) i PWA-versjonen - Content Security Policy (CSP) i PWA-versjonen
- Tilfluktsromdata valideres ved parsing (koordinater innenfor Norge, gyldige felt) - Tilfluktsromdata valideres ved parsing (koordinater innenfor Norge, gyldige felt)
- Databaseoppdateringer er atomiske (transaksjon) for å unngå datatap - Databaseoppdateringer er atomiske (transaksjon) for å unngå datatap
- Lagret GPS-posisjon utløper automatisk etter 24 timer - GPS-posisjon lagres ikke — den brukes bare i minnet mens appen er i bruk
- Egendefinert User-Agent forhindrer enhetsfingeravtrykk - Egendefinert User-Agent forhindrer enhetsfingeravtrykk
## Personvern ## Personvern

View file

@ -80,7 +80,7 @@ engineers must be in orbit, in your pocket, and on the circuit board.
| Library | What it does | Contributors | Source | | Library | What it does | Contributors | Source |
|---|---|---|---| |---|---|---|---|
| AndroidX (Core, AppCompat, Room, WorkManager, etc.) | UI, architecture, database, scheduling | ~1,000 | [GitHub: androidx/androidx](https://github.com/androidx/androidx) monorepo | | AndroidX (Core, AppCompat, Room, etc.) | UI, architecture, database | ~1,000 | [GitHub: androidx/androidx](https://github.com/androidx/androidx) monorepo |
| Material Design Components | Visual design language and components | ~199 | [GitHub: material-components-android](https://github.com/material-components/material-components-android) | | Material Design Components | Visual design language and components | ~199 | [GitHub: material-components-android](https://github.com/material-components/material-components-android) |
| Kotlinx Coroutines | Async data loading without blocking the UI | ~308 | [GitHub: Kotlin/kotlinx.coroutines](https://github.com/Kotlin/kotlinx.coroutines) | | Kotlinx Coroutines | Async data loading without blocking the UI | ~308 | [GitHub: Kotlin/kotlinx.coroutines](https://github.com/Kotlin/kotlinx.coroutines) |
| OkHttp | Downloads the GeoJSON ZIP from Geonorge | ~287 | [GitHub: square/okhttp](https://github.com/square/okhttp) | | OkHttp | Downloads the GeoJSON ZIP from Geonorge | ~287 | [GitHub: square/okhttp](https://github.com/square/okhttp) |

View file

@ -104,9 +104,6 @@ dependencies {
// Google Play Services Location (precise GPS) — standard flavor only // Google Play Services Location (precise GPS) — standard flavor only
"standardImplementation"("com.google.android.gms:play-services-location:21.3.0") "standardImplementation"("com.google.android.gms:play-services-location:21.3.0")
// WorkManager (periodic widget updates)
implementation("androidx.work:work-runtime-ktx:2.9.1")
// Coroutines // Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")

View file

@ -1,268 +0,0 @@
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.os.Build
import android.os.CancellationSignal
import android.text.format.DateFormat
import android.util.Log
import android.widget.RemoteViews
import androidx.core.content.ContextCompat
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
import java.util.concurrent.TimeUnit
/**
* Home screen widget showing the nearest shelter with distance.
*
* F-Droid flavor: uses LocationManager only (no Google Play Services).
*/
class ShelterWidgetProvider : AppWidgetProvider() {
companion object {
private const val TAG = "ShelterWidget"
const val ACTION_REFRESH = "no.naiv.tilfluktsrom.widget.REFRESH"
private const val EXTRA_LATITUDE = "lat"
private const val EXTRA_LONGITUDE = "lon"
fun requestUpdate(context: Context) {
val intent = Intent(context, ShelterWidgetProvider::class.java).apply {
action = ACTION_REFRESH
}
context.sendBroadcast(intent)
}
fun requestUpdateWithLocation(context: Context, latitude: Double, longitude: Double) {
val intent = Intent(context, ShelterWidgetProvider::class.java).apply {
action = ACTION_REFRESH
putExtra(EXTRA_LATITUDE, latitude)
putExtra(EXTRA_LONGITUDE, longitude)
}
context.sendBroadcast(intent)
}
}
override fun onEnabled(context: Context) {
super.onEnabled(context)
WidgetUpdateWorker.schedule(context)
}
override fun onDisabled(context: Context) {
super.onDisabled(context)
WidgetUpdateWorker.cancel(context)
}
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
WidgetUpdateWorker.schedule(context)
updateAllWidgetsAsync(context, null)
}
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
if (intent.action == ACTION_REFRESH) {
val providedLocation = if (intent.hasExtra(EXTRA_LATITUDE)) {
Location("widget").apply {
latitude = intent.getDoubleExtra(EXTRA_LATITUDE, 0.0)
longitude = intent.getDoubleExtra(EXTRA_LONGITUDE, 0.0)
}
} else null
updateAllWidgetsAsync(context, providedLocation)
}
}
private fun updateAllWidgetsAsync(context: Context, providedLocation: Location?) {
val pendingResult = goAsync()
Thread {
try {
val appWidgetManager = AppWidgetManager.getInstance(context)
val widgetIds = appWidgetManager.getAppWidgetIds(
ComponentName(context, ShelterWidgetProvider::class.java)
)
val location = providedLocation ?: getBestLocation(context)
for (appWidgetId in widgetIds) {
updateWidget(context, appWidgetManager, appWidgetId, location)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to update widgets", e)
} finally {
pendingResult.finish()
}
}.start()
}
private fun updateWidget(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int,
location: Location?
) {
val views = RemoteViews(context.packageName, R.layout.widget_nearest_shelter)
val openAppIntent = Intent(context, MainActivity::class.java)
val openAppPending = PendingIntent.getActivity(
context, 0, openAppIntent, PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.widgetRoot, openAppPending)
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)
if (ContextCompat.checkSelfPermission(
context, Manifest.permission.ACCESS_FINE_LOCATION
) != PackageManager.PERMISSION_GRANTED
) {
showFallback(context, views, context.getString(R.string.widget_open_app))
appWidgetManager.updateAppWidget(appWidgetId, views)
return
}
if (location == null) {
showFallback(context, views, context.getString(R.string.widget_no_location))
appWidgetManager.updateAppWidget(appWidgetId, views)
return
}
val shelters = try {
val dao = ShelterDatabase.getInstance(context).shelterDao()
kotlinx.coroutines.runBlocking { dao.getAllSheltersList() }
} catch (e: Exception) {
Log.e(TAG, "Failed to query shelters", e)
emptyList()
}
if (shelters.isEmpty()) {
showFallback(context, views, context.getString(R.string.widget_no_data))
appWidgetManager.updateAppWidget(appWidgetId, views)
return
}
val nearest = ShelterFinder.findNearest(
shelters, location.latitude, location.longitude, 1
).firstOrNull()
if (nearest == null) {
showFallback(context, views, context.getString(R.string.widget_no_data))
appWidgetManager.updateAppWidget(appWidgetId, views)
return
}
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)
)
views.setTextViewText(R.id.widgetTimestamp, formatTimestamp(context))
appWidgetManager.updateAppWidget(appWidgetId, views)
}
private fun showFallback(context: Context, views: RemoteViews, message: String) {
views.setTextViewText(R.id.widgetAddress, message)
views.setTextViewText(R.id.widgetDetails, "")
views.setTextViewText(R.id.widgetDistance, "")
views.setTextViewText(R.id.widgetTimestamp, formatTimestamp(context))
}
private fun formatTimestamp(context: Context): String {
val format = DateFormat.getTimeFormat(context)
val timeStr = format.format(System.currentTimeMillis())
return context.getString(R.string.widget_updated_at, timeStr)
}
/**
* Get the best available location via LocationManager or SharedPreferences.
* Safe to call from a background thread.
*/
private fun getBestLocation(context: Context): Location? {
if (ContextCompat.checkSelfPermission(
context, Manifest.permission.ACCESS_FINE_LOCATION
) != PackageManager.PERMISSION_GRANTED
) return null
val lmLocation = getLocationManagerLocation(context)
if (lmLocation != null) return lmLocation
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()
}
}
private fun getLocationManagerLocation(context: Context): Location? {
val locationManager = context.getSystemService(Context.LOCATION_SERVICE)
as? LocationManager ?: return null
try {
val lastGps = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
val lastNetwork = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)
val cached = listOfNotNull(lastGps, lastNetwork).maxByOrNull { it.time }
if (cached != null) return cached
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException getting last known location", e)
return null
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val provider = when {
locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) ->
LocationManager.NETWORK_PROVIDER
locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) ->
LocationManager.GPS_PROVIDER
else -> return null
}
try {
val latch = java.util.concurrent.CountDownLatch(1)
var result: Location? = null
val signal = CancellationSignal()
locationManager.getCurrentLocation(
provider, signal, context.mainExecutor
) { location ->
result = location
latch.countDown()
}
latch.await(10, TimeUnit.SECONDS)
signal.cancel()
return result
} catch (e: Exception) {
Log.e(TAG, "Active location request failed", e)
}
}
return null
}
}

View file

@ -1,136 +0,0 @@
package no.naiv.tilfluktsrom.widget
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationManager
import android.os.Build
import android.os.CancellationSignal
import android.util.Log
import androidx.core.content.ContextCompat
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
/**
* Periodic background worker that refreshes the home screen widget.
*
* F-Droid flavor: uses LocationManager only (no Google Play Services).
*/
class WidgetUpdateWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
companion object {
private const val TAG = "WidgetUpdateWorker"
private const val WORK_NAME = "widget_update"
private const val LOCATION_TIMEOUT_MS = 10_000L
fun schedule(context: Context) {
val request = PeriodicWorkRequestBuilder<WidgetUpdateWorker>(
15, TimeUnit.MINUTES
).build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
request
)
}
fun runOnce(context: Context) {
val request = OneTimeWorkRequestBuilder<WidgetUpdateWorker>().build()
WorkManager.getInstance(context).enqueue(request)
}
fun cancel(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
}
}
override suspend fun doWork(): Result {
val location = requestFreshLocation() ?: getSavedLocation()
if (location != null) {
ShelterWidgetProvider.requestUpdateWithLocation(
applicationContext, location.latitude, location.longitude
)
} else {
ShelterWidgetProvider.requestUpdate(applicationContext)
}
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()
}
}
private suspend fun requestFreshLocation(): Location? {
val context = applicationContext
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
!= PackageManager.PERMISSION_GRANTED
) return null
return requestViaLocationManager()
}
private suspend fun requestViaLocationManager(): Location? {
val locationManager = applicationContext.getSystemService(Context.LOCATION_SERVICE)
as? LocationManager ?: return null
val provider = when {
locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) ->
LocationManager.GPS_PROVIDER
locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) ->
LocationManager.NETWORK_PROVIDER
else -> return null
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
return requestCurrentLocation(locationManager, provider)
}
// API 26-29: fall back to passive cache
return try {
locationManager.getLastKnownLocation(provider)
} catch (e: SecurityException) {
null
}
}
private suspend fun requestCurrentLocation(locationManager: LocationManager, provider: String): Location? {
return try {
withTimeoutOrNull(LOCATION_TIMEOUT_MS) {
suspendCancellableCoroutine<Location?> { cont ->
val signal = CancellationSignal()
locationManager.getCurrentLocation(
provider,
signal,
applicationContext.mainExecutor
) { location ->
if (cont.isActive) cont.resume(location)
}
cont.invokeOnCancellation { signal.cancel() }
}
}
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException requesting location via LocationManager", e)
null
}
}
}

View file

@ -39,18 +39,5 @@
android:pathPrefix="/shelter/" /> android:pathPrefix="/shelter/" />
</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>

View file

@ -41,7 +41,6 @@ import no.naiv.tilfluktsrom.location.ShelterWithDistance
import no.naiv.tilfluktsrom.ui.CivilDefenseInfoDialog import no.naiv.tilfluktsrom.ui.CivilDefenseInfoDialog
import no.naiv.tilfluktsrom.ui.ShelterListAdapter import no.naiv.tilfluktsrom.ui.ShelterListAdapter
import no.naiv.tilfluktsrom.util.DistanceUtils import no.naiv.tilfluktsrom.util.DistanceUtils
import no.naiv.tilfluktsrom.widget.ShelterWidgetProvider
import org.osmdroid.util.GeoPoint import org.osmdroid.util.GeoPoint
import org.osmdroid.views.CustomZoomButtonsController import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.overlay.Marker import org.osmdroid.views.overlay.Marker
@ -386,7 +385,6 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
try { try {
locationProvider.locationUpdates().collectLatest { location -> locationProvider.locationUpdates().collectLatest { location ->
currentLocation = location currentLocation = location
saveLastLocation(location)
updateNearestShelters(location) updateNearestShelters(location)
// Center map on first location fix // Center map on first location fix
@ -439,7 +437,6 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
} }
updateSelectedShelterUI() updateSelectedShelterUI()
ShelterWidgetProvider.requestUpdate(this)
} }
/** /**
@ -745,15 +742,6 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
binding.selectedShelterDetails.text = getString(R.string.status_shelters_loaded, allShelters.size) binding.selectedShelterDetails.text = getString(R.string.status_shelters_loaded, allShelters.size)
} }
/** Persist last GPS fix so the widget can use it even when the app isn't running. */
private fun saveLastLocation(location: Location) {
getSharedPreferences("widget_prefs", Context.MODE_PRIVATE).edit()
.putFloat("last_lat", location.latitude.toFloat())
.putFloat("last_lon", location.longitude.toFloat())
.putLong("last_time", System.currentTimeMillis())
.apply()
}
private fun isNetworkAvailable(): Boolean { private fun isNetworkAvailable(): Boolean {
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return false ?: return false

View file

@ -1,74 +0,0 @@
<?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" />
<TextView
android:id="@+id/widgetTimestamp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_secondary"
android:textSize="9sp"
tools:text="14:32" />
</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="48dp"
android:layout_height="48dp"
android:layout_marginStart="4dp"
android:padding="8dp"
android:contentDescription="@string/action_refresh"
android:src="@drawable/ic_refresh" />
</LinearLayout>

View file

@ -20,7 +20,6 @@
<!-- Tilfluktsrominfo --> <!-- Tilfluktsrominfo -->
<string name="shelter_capacity">%d plasser</string> <string name="shelter_capacity">%d plasser</string>
<string name="shelter_room_nr">Rom %d</string> <string name="shelter_room_nr">Rom %d</string>
<string name="nearest_shelter">Nærmeste tilfluktsrom</string>
<string name="no_shelters">Ingen tilfluktsromdata tilgjengelig</string> <string name="no_shelters">Ingen tilfluktsromdata tilgjengelig</string>
<!-- Handlinger --> <!-- Handlinger -->
@ -46,13 +45,6 @@
<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>
<string name="widget_updated_at">Oppdatert %s</string>
<!-- Dataferskhet --> <!-- Dataferskhet -->
<string name="freshness_fresh">Data er oppdatert</string> <string name="freshness_fresh">Data er oppdatert</string>
<string name="freshness_week">Data er %d dager gammel</string> <string name="freshness_week">Data er %d dager gammel</string>
@ -102,7 +94,7 @@
<string name="about_data_title">Datakilder</string> <string name="about_data_title">Datakilder</string>
<string name="about_data_body">Tilfluktsromdata er offentlig informasjon fra DSB (Direktoratet for samfunnssikkerhet og beredskap), distribuert via Geonorge. Kartfliser lastes fra OpenStreetMap. Begge lagres lokalt for frakoblet bruk.</string> <string name="about_data_body">Tilfluktsromdata er offentlig informasjon fra DSB (Direktoratet for samfunnssikkerhet og beredskap), distribuert via Geonorge. 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_title">Lagret på enheten din</string>
<string name="about_stored_body">• Tilfluktsromdatabase (offentlige data fra DSB)\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_stored_body">• Tilfluktsromdatabase (offentlige data fra DSB)\n• Kartfliser for frakoblet bruk\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="about_open_source">Åpen kildekode — kode.naiv.no/olemd/tilfluktsrom</string>
<string name="action_about">Om denne appen</string> <string name="action_about">Om denne appen</string>

View file

@ -20,7 +20,6 @@
<!-- Tilfluktsrominfo --> <!-- Tilfluktsrominfo -->
<string name="shelter_capacity">%d plassar</string> <string name="shelter_capacity">%d plassar</string>
<string name="shelter_room_nr">Rom %d</string> <string name="shelter_room_nr">Rom %d</string>
<string name="nearest_shelter">Næraste tilfluktsrom</string>
<string name="no_shelters">Ingen tilfluktsromdata tilgjengeleg</string> <string name="no_shelters">Ingen tilfluktsromdata tilgjengeleg</string>
<!-- Handlingar --> <!-- Handlingar -->
@ -46,13 +45,6 @@
<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>
<string name="widget_updated_at">Oppdatert %s</string>
<!-- Dataferskheit --> <!-- Dataferskheit -->
<string name="freshness_fresh">Data er oppdatert</string> <string name="freshness_fresh">Data er oppdatert</string>
<string name="freshness_week">Data er %d dagar gammal</string> <string name="freshness_week">Data er %d dagar gammal</string>
@ -102,7 +94,7 @@
<string name="about_data_title">Datakjelder</string> <string name="about_data_title">Datakjelder</string>
<string name="about_data_body">Tilfluktsromdata er offentleg informasjon frå DSB (Direktoratet for samfunnstryggleik og beredskap), distribuert via Geonorge. Kartfliser vert lasta frå OpenStreetMap. Begge vert lagra lokalt for fråkopla bruk.</string> <string name="about_data_body">Tilfluktsromdata er offentleg informasjon frå DSB (Direktoratet for samfunnstryggleik og beredskap), distribuert via Geonorge. 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_title">Lagra på eininga di</string>
<string name="about_stored_body">• Tilfluktsromdatabase (offentlege data frå DSB)\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_stored_body">• Tilfluktsromdatabase (offentlege data frå DSB)\n• Kartfliser for fråkopla bruk\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="about_open_source">Open kjeldekode — kode.naiv.no/olemd/tilfluktsrom</string>
<string name="action_about">Om denne appen</string> <string name="action_about">Om denne appen</string>

View file

@ -20,7 +20,6 @@
<!-- Shelter info --> <!-- Shelter info -->
<string name="shelter_capacity">%d places</string> <string name="shelter_capacity">%d places</string>
<string name="shelter_room_nr">Room %d</string> <string name="shelter_room_nr">Room %d</string>
<string name="nearest_shelter">Nearest shelter</string>
<string name="no_shelters">No shelter data available</string> <string name="no_shelters">No shelter data available</string>
<!-- Actions --> <!-- Actions -->
@ -46,13 +45,6 @@
<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>
<string name="widget_updated_at">Updated %s</string>
<!-- Data freshness --> <!-- Data freshness -->
<string name="freshness_fresh">Data is up to date</string> <string name="freshness_fresh">Data is up to date</string>
<string name="freshness_week">Data is %d days old</string> <string name="freshness_week">Data is %d days old</string>
@ -103,7 +95,7 @@
<string name="about_data_title">Data sources</string> <string name="about_data_title">Data sources</string>
<string name="about_data_body">Shelter data is public information from DSB (Norwegian Directorate for Civil Protection), distributed via Geonorge. Map tiles are loaded from OpenStreetMap. Both are cached locally for offline use.</string> <string name="about_data_body">Shelter data is public information from DSB (Norwegian Directorate for Civil Protection), distributed via Geonorge. 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_title">Stored on your device</string>
<string name="about_stored_body">• Shelter database (public data from DSB)\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_stored_body">• Shelter database (public data from DSB)\n• Map tiles for offline use\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="about_open_source">Open source — kode.naiv.no/olemd/tilfluktsrom</string>
<string name="action_about">About this app</string> <string name="action_about">About this app</string>

View file

@ -1,9 +0,0 @@
<?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,349 +0,0 @@
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.os.Build
import android.os.CancellationSignal
import android.text.format.DateFormat
import android.util.Log
import android.widget.RemoteViews
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.LocationServices
import com.google.android.gms.location.Priority
import com.google.android.gms.tasks.Tasks
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
import java.util.concurrent.TimeUnit
/**
* Home screen widget showing the nearest shelter with distance.
*
* Update strategy:
* - Background: WorkManager runs every 15 min while widget exists
* - Live: MainActivity sends ACTION_REFRESH on each GPS location update
* - Manual: user taps the refresh button on the widget
*
* Location resolution (in priority order):
* 1. Location provided via intent extras (from WorkManager or MainActivity)
* 2. FusedLocationProviderClient cache/active request (Play Services)
* 3. LocationManager cache/active request (AOSP fallback)
* 4. Last GPS fix saved to SharedPreferences by MainActivity
*
* Note: Background processes cannot reliably trigger GPS hardware on
* Android 8+. The SharedPreferences fallback ensures the widget works
* after app updates and reboots without opening the app first.
*/
class ShelterWidgetProvider : AppWidgetProvider() {
companion object {
private const val TAG = "ShelterWidget"
const val ACTION_REFRESH = "no.naiv.tilfluktsrom.widget.REFRESH"
private const val EXTRA_LATITUDE = "lat"
private const val EXTRA_LONGITUDE = "lon"
/** Trigger a widget refresh from anywhere (e.g. MainActivity on location update). */
fun requestUpdate(context: Context) {
val intent = Intent(context, ShelterWidgetProvider::class.java).apply {
action = ACTION_REFRESH
}
context.sendBroadcast(intent)
}
/** Trigger a widget refresh with a known location (from WidgetUpdateWorker). */
fun requestUpdateWithLocation(context: Context, latitude: Double, longitude: Double) {
val intent = Intent(context, ShelterWidgetProvider::class.java).apply {
action = ACTION_REFRESH
putExtra(EXTRA_LATITUDE, latitude)
putExtra(EXTRA_LONGITUDE, longitude)
}
context.sendBroadcast(intent)
}
}
override fun onEnabled(context: Context) {
super.onEnabled(context)
WidgetUpdateWorker.schedule(context)
}
override fun onDisabled(context: Context) {
super.onDisabled(context)
WidgetUpdateWorker.cancel(context)
}
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
WidgetUpdateWorker.schedule(context)
updateAllWidgetsAsync(context, null)
}
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
if (intent.action == ACTION_REFRESH) {
val providedLocation = if (intent.hasExtra(EXTRA_LATITUDE)) {
Location("widget").apply {
latitude = intent.getDoubleExtra(EXTRA_LATITUDE, 0.0)
longitude = intent.getDoubleExtra(EXTRA_LONGITUDE, 0.0)
}
} else null
updateAllWidgetsAsync(context, providedLocation)
}
}
/**
* Run widget update on a background thread so we can call
* FusedLocationProviderClient.getLastLocation() synchronously.
* Uses goAsync() to keep the BroadcastReceiver alive until done.
*/
private fun updateAllWidgetsAsync(context: Context, providedLocation: Location?) {
val pendingResult = goAsync()
Thread {
try {
val appWidgetManager = AppWidgetManager.getInstance(context)
val widgetIds = appWidgetManager.getAppWidgetIds(
ComponentName(context, ShelterWidgetProvider::class.java)
)
val location = providedLocation ?: getBestLocation(context)
for (appWidgetId in widgetIds) {
updateWidget(context, appWidgetManager, appWidgetId, location)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to update widgets", e)
} finally {
pendingResult.finish()
}
}.start()
}
private fun updateWidget(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int,
location: Location?
) {
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 permission
if (ContextCompat.checkSelfPermission(
context, Manifest.permission.ACCESS_FINE_LOCATION
) != PackageManager.PERMISSION_GRANTED
) {
showFallback(context, views, context.getString(R.string.widget_open_app))
appWidgetManager.updateAppWidget(appWidgetId, views)
return
}
if (location == null) {
showFallback(context, 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()
kotlinx.coroutines.runBlocking { dao.getAllSheltersList() }
} catch (e: Exception) {
Log.e(TAG, "Failed to query shelters", e)
emptyList()
}
if (shelters.isEmpty()) {
showFallback(context, 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(context, 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)
)
views.setTextViewText(R.id.widgetTimestamp, formatTimestamp(context))
appWidgetManager.updateAppWidget(appWidgetId, views)
}
/** Show a fallback message when location or data is unavailable. */
private fun showFallback(context: Context, views: RemoteViews, message: String) {
views.setTextViewText(R.id.widgetAddress, message)
views.setTextViewText(R.id.widgetDetails, "")
views.setTextViewText(R.id.widgetDistance, "")
views.setTextViewText(R.id.widgetTimestamp, formatTimestamp(context))
}
/** Format current time as "Updated HH:mm", respecting the user's 12/24h preference. */
private fun formatTimestamp(context: Context): String {
val format = DateFormat.getTimeFormat(context)
val timeStr = format.format(System.currentTimeMillis())
return context.getString(R.string.widget_updated_at, timeStr)
}
/**
* Get the best available location.
* Tries FusedLocationProviderClient first (Play Services, better cache),
* then LocationManager (AOSP), then last saved GPS fix from SharedPreferences.
* Safe to call from a background thread.
*/
private fun getBestLocation(context: Context): Location? {
if (ContextCompat.checkSelfPermission(
context, Manifest.permission.ACCESS_FINE_LOCATION
) != PackageManager.PERMISSION_GRANTED
) return null
// Try Play Services first — maintains a better location cache
val fusedLocation = getFusedLastLocation(context)
if (fusedLocation != null) return fusedLocation
// Fall back to LocationManager
val lmLocation = getLocationManagerLocation(context)
if (lmLocation != null) return lmLocation
// Fall back to last location saved by MainActivity
return getSavedLocation(context)
}
/** 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()
}
}
/**
* Get location via Play Services blocks, call from background thread.
* Tries cached location first, then actively requests a fix if cache is empty.
*/
private fun getFusedLastLocation(context: Context): Location? {
if (!isPlayServicesAvailable(context)) return null
return try {
val client = LocationServices.getFusedLocationProviderClient(context)
// Try cache first (instant)
val cached = Tasks.await(client.lastLocation, 3, TimeUnit.SECONDS)
if (cached != null) return cached
// Cache empty — actively request a fix (turns on GPS/network)
val task = client.getCurrentLocation(
Priority.PRIORITY_BALANCED_POWER_ACCURACY, null
)
Tasks.await(task, 10, TimeUnit.SECONDS)
} catch (e: Exception) {
Log.w(TAG, "FusedLocationProvider failed", e)
null
}
}
/**
* Get location via LocationManager (AOSP).
* Tries cache first, then actively requests a fix on API 30+.
* Blocks call from background thread.
*/
private fun getLocationManagerLocation(context: Context): Location? {
val locationManager = context.getSystemService(Context.LOCATION_SERVICE)
as? LocationManager ?: return null
// Try cache first
try {
val lastGps = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
val lastNetwork = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)
val cached = listOfNotNull(lastGps, lastNetwork).maxByOrNull { it.time }
if (cached != null) return cached
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException getting last known location", e)
return null
}
// Cache empty — actively request on API 30+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val provider = when {
locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) ->
LocationManager.NETWORK_PROVIDER
locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) ->
LocationManager.GPS_PROVIDER
else -> return null
}
try {
val latch = java.util.concurrent.CountDownLatch(1)
var result: Location? = null
val signal = CancellationSignal()
locationManager.getCurrentLocation(
provider, signal, context.mainExecutor
) { location ->
result = location
latch.countDown()
}
latch.await(10, TimeUnit.SECONDS)
signal.cancel()
return result
} catch (e: Exception) {
Log.e(TAG, "Active location request failed", e)
}
}
return null
}
private fun isPlayServicesAvailable(context: Context): Boolean {
return try {
val result = GoogleApiAvailability.getInstance()
.isGooglePlayServicesAvailable(context)
result == ConnectionResult.SUCCESS
} catch (e: Exception) {
false
}
}
}

View file

@ -1,187 +0,0 @@
package no.naiv.tilfluktsrom.widget
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationManager
import android.os.Build
import android.os.CancellationSignal
import android.util.Log
import androidx.core.content.ContextCompat
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import com.google.android.gms.tasks.Tasks
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
/**
* Periodic background worker that refreshes the home screen widget.
*
* Scheduled every 15 minutes (WorkManager's minimum interval).
* Actively requests a fresh location fix to populate the system cache,
* then triggers the widget's existing update logic via broadcast.
*
* Location strategy (mirrors LocationProvider):
* - Play Services: FusedLocationProviderClient.getCurrentLocation()
* - AOSP API 30+: LocationManager.getCurrentLocation()
* - AOSP API 26-29: LocationManager.getLastKnownLocation()
*/
class WidgetUpdateWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
companion object {
private const val TAG = "WidgetUpdateWorker"
private const val WORK_NAME = "widget_update"
private const val LOCATION_TIMEOUT_MS = 10_000L
/** Schedule periodic widget updates. Safe to call multiple times. */
fun schedule(context: Context) {
val request = PeriodicWorkRequestBuilder<WidgetUpdateWorker>(
15, TimeUnit.MINUTES
).build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
request
)
}
/** Run once immediately (e.g. when widget is first placed or location was unavailable). */
fun runOnce(context: Context) {
val request = OneTimeWorkRequestBuilder<WidgetUpdateWorker>().build()
WorkManager.getInstance(context).enqueue(request)
}
/** Cancel periodic updates (e.g. when all widgets are removed). */
fun cancel(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
}
}
override suspend fun doWork(): Result {
val location = requestFreshLocation() ?: getSavedLocation()
if (location != null) {
ShelterWidgetProvider.requestUpdateWithLocation(
applicationContext, location.latitude, location.longitude
)
} else {
ShelterWidgetProvider.requestUpdate(applicationContext)
}
return Result.success()
}
/** 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()
}
}
/**
* Actively request a location fix and return it.
* Returns null if permission is missing or location is unavailable.
*/
private suspend fun requestFreshLocation(): Location? {
val context = applicationContext
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
!= PackageManager.PERMISSION_GRANTED
) return null
return if (isPlayServicesAvailable()) {
requestViaPlayServices()
} else {
requestViaLocationManager()
}
}
/** Use FusedLocationProviderClient.getCurrentLocation() — best accuracy, best cache. */
private suspend fun requestViaPlayServices(): Location? {
return try {
val client = LocationServices.getFusedLocationProviderClient(applicationContext)
val task = client.getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, null)
Tasks.await(task, LOCATION_TIMEOUT_MS, TimeUnit.MILLISECONDS)
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException requesting location via Play Services", e)
null
} catch (e: Exception) {
Log.w(TAG, "Play Services location request failed, falling back", e)
requestViaLocationManager()
}
}
/** Use LocationManager.getCurrentLocation() (API 30+) or getLastKnownLocation() fallback. */
private suspend fun requestViaLocationManager(): Location? {
val locationManager = applicationContext.getSystemService(Context.LOCATION_SERVICE)
as? LocationManager ?: return null
val provider = when {
locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) ->
LocationManager.GPS_PROVIDER
locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) ->
LocationManager.NETWORK_PROVIDER
else -> return null
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
return requestCurrentLocation(locationManager, provider)
}
// API 26-29: fall back to passive cache
return try {
locationManager.getLastKnownLocation(provider)
} catch (e: SecurityException) {
null
}
}
/** API 30+: actively request a single location fix. */
private suspend fun requestCurrentLocation(locationManager: LocationManager, provider: String): Location? {
return try {
withTimeoutOrNull(LOCATION_TIMEOUT_MS) {
suspendCancellableCoroutine<Location?> { cont ->
val signal = CancellationSignal()
locationManager.getCurrentLocation(
provider,
signal,
applicationContext.mainExecutor
) { location ->
if (cont.isActive) cont.resume(location)
}
cont.invokeOnCancellation { signal.cancel() }
}
}
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException requesting location via LocationManager", e)
null
}
}
private fun isPlayServicesAvailable(): Boolean {
return try {
val result = GoogleApiAvailability.getInstance()
.isGooglePlayServicesAvailable(applicationContext)
result == ConnectionResult.SUCCESS
} catch (e: Exception) {
false
}
}
}

View file

@ -5,7 +5,6 @@ Features:
• Compass navigation — direction arrow points to the selected shelter • Compass navigation — direction arrow points to the selected shelter
• Offline map — map tiles are cached automatically for use without internet • Offline map — map tiles are cached automatically for use without internet
• Select any shelter — tap any marker on the map to navigate there • Select any shelter — tap any marker on the map to navigate there
• Home screen widget — shows nearest shelter at a glance
• Share shelters — send shelter location to others via any app • Share shelters — send shelter location to others via any app
• Civil defense info — what to do if the alarm sounds • Civil defense info — what to do if the alarm sounds
• Multilingual — English, Bokmål, and Nynorsk • Multilingual — English, Bokmål, and Nynorsk

View file

@ -5,7 +5,6 @@ Funksjoner:
• Kompassnavigasjon — retningspil som peker mot valgt tilfluktsrom • Kompassnavigasjon — retningspil som peker mot valgt tilfluktsrom
• Frakoblet kart — kartfliser lagres automatisk for bruk uten nett • Frakoblet kart — kartfliser lagres automatisk for bruk uten nett
• Velg fritt — trykk på en markør i kartet for å navigere dit • Velg fritt — trykk på en markør i kartet for å navigere dit
• Hjemskjerm-widget — viser nærmeste tilfluktsrom med ett blikk
• Del tilfluktsrom — send posisjon til andre via en hvilken som helst app • Del tilfluktsrom — send posisjon til andre via en hvilken som helst app
• Sivilforsvarsinformasjon — hva du skal gjøre hvis alarmen går • Sivilforsvarsinformasjon — hva du skal gjøre hvis alarmen går
• Flerspråklig — engelsk, bokmål og nynorsk • Flerspråklig — engelsk, bokmål og nynorsk

View file

@ -5,7 +5,6 @@ Funksjonar:
• Kompassnavigasjon — retningspil som peikar mot valt tilfluktsrom • Kompassnavigasjon — retningspil som peikar mot valt tilfluktsrom
• Fråkopla kart — kartfliser lagrast automatisk for bruk utan nett • Fråkopla kart — kartfliser lagrast automatisk for bruk utan nett
• Vel fritt — trykk på ein markør i kartet for å navigere dit • Vel fritt — trykk på ein markør i kartet for å navigere dit
• Heimeskjerm-widget — viser næraste tilfluktsrom med eitt blikk
• Del tilfluktsrom — send posisjon til andre via ei kva som helst app • Del tilfluktsrom — send posisjon til andre via ei kva som helst app
• Sivilforsvarsinformasjon — kva du skal gjere om alarmen går • Sivilforsvarsinformasjon — kva du skal gjere om alarmen går
• Fleirspråkleg — engelsk, bokmål og nynorsk • Fleirspråkleg — engelsk, bokmål og nynorsk