Compare commits
2 commits
4e6c03c301
...
7ce0827e9f
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ce0827e9f | |||
| 0743eac9dd |
19 changed files with 10 additions and 1112 deletions
|
|
@ -13,7 +13,6 @@ This document describes the architecture of Tilfluktsrom, a Norwegian emergency
|
|||
- [Compass System](#compass-system)
|
||||
- [Map & Tile Caching](#map--tile-caching)
|
||||
- [Build Variants](#build-variants)
|
||||
- [Home Screen Widget](#home-screen-widget)
|
||||
- [Deep Linking](#deep-linking)
|
||||
- [Progressive Web App](#progressive-web-app)
|
||||
- [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.
|
||||
|
||||
### 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
|
||||
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
|
||||
│ ├── CivilDefenseInfoDialog.kt # Emergency instructions
|
||||
│ └── AboutDialog.kt # Privacy and copyright
|
||||
├── util/
|
||||
│ ├── CoordinateConverter.kt # UTM33N → WGS84 (Karney method)
|
||||
│ └── DistanceUtils.kt # Haversine distance and bearing
|
||||
└── widget/
|
||||
├── ShelterWidgetProvider.kt # Home screen widget (flavor-specific)
|
||||
└── WidgetUpdateWorker.kt # WorkManager periodic update
|
||||
└── util/
|
||||
├── CoordinateConverter.kt # UTM33N → WGS84 (Karney method)
|
||||
└── DistanceUtils.kt # Haversine distance and bearing
|
||||
```
|
||||
|
||||
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/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.
|
||||
|
||||
### 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
|
||||
|
||||
**HTTPS App Links:** `https://tilfluktsrom.naiv.no/shelter/{lokalId}`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
- **Maps**: OSMDroid (no Google dependency)
|
||||
- **Database**: Room/SQLite (no Google dependency)
|
||||
- **Background work**: WorkManager (works without Play Services via built-in scheduler)
|
||||
|
||||
### 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.
|
||||
|
|
@ -24,7 +23,6 @@ This is an emergency app. Assume internet and infrastructure may be degraded or
|
|||
- **Database**: Room (SQLite) for shelter data cache
|
||||
- **HTTP**: OkHttp for data downloads
|
||||
- **Location**: FusedLocationProviderClient (Play Services) with LocationManager fallback
|
||||
- **Background**: WorkManager for periodic widget updates
|
||||
- **UI**: Traditional Views with ViewBinding
|
||||
|
||||
## Key Data Flow
|
||||
|
|
@ -44,7 +42,6 @@ no.naiv.tilfluktsrom/
|
|||
├── data/ # Room entities, DAO, repository, GeoJSON parser, map cache
|
||||
├── location/ # GPS location provider, nearest shelter finder
|
||||
├── ui/ # Custom views (DirectionArrowView), adapters
|
||||
├── widget/ # Home screen widget, WorkManager periodic updater
|
||||
└── util/ # Coordinate conversion (UTM→WGS84), distance calculations
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
- **Del tilfluktsrom** — send adresse, kapasitet og koordinater til andre
|
||||
- **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
|
||||
- **Tilgjengelighet** — TalkBack-støtte, fokusindikatorer og tilstrekkelig kontrast
|
||||
|
||||
|
|
@ -70,7 +69,6 @@ tilfluktsrom/
|
|||
│ │ ├── data/ # Room-database, nedlasting, GeoJSON-parser
|
||||
│ │ ├── location/ # GPS, nærmeste tilfluktsrom
|
||||
│ │ ├── ui/ # Retningspil, liste-adapter, om-dialog
|
||||
│ │ ├── widget/ # Hjemmeskjerm-widget
|
||||
│ │ └── util/ # UTM→WGS84-konvertering, avstandsberegning
|
||||
│ └── res/ # Layout, strenger (en/nb/nn), ikoner
|
||||
├── pwa/ # Nettapp (TypeScript)
|
||||
|
|
@ -99,7 +97,7 @@ Appen er designet etter «offline-first»-prinsippet:
|
|||
- Content Security Policy (CSP) i PWA-versjonen
|
||||
- Tilfluktsromdata valideres ved parsing (koordinater innenfor Norge, gyldige felt)
|
||||
- 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
|
||||
|
||||
## Personvern
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ engineers must be in orbit, in your pocket, and on the circuit board.
|
|||
|
||||
| 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) |
|
||||
| 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) |
|
||||
|
|
|
|||
|
|
@ -104,9 +104,6 @@ dependencies {
|
|||
// Google Play Services Location (precise GPS) — standard flavor only
|
||||
"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
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -39,18 +39,5 @@
|
|||
android:pathPrefix="/shelter/" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver
|
||||
android:name=".widget.ShelterWidgetProvider"
|
||||
android:exported="true"
|
||||
android:label="@string/nearest_shelter">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
<action android:name="no.naiv.tilfluktsrom.widget.REFRESH" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_info" />
|
||||
</receiver>
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ import no.naiv.tilfluktsrom.location.ShelterWithDistance
|
|||
import no.naiv.tilfluktsrom.ui.CivilDefenseInfoDialog
|
||||
import no.naiv.tilfluktsrom.ui.ShelterListAdapter
|
||||
import no.naiv.tilfluktsrom.util.DistanceUtils
|
||||
import no.naiv.tilfluktsrom.widget.ShelterWidgetProvider
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.views.CustomZoomButtonsController
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
|
|
@ -386,7 +385,6 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
try {
|
||||
locationProvider.locationUpdates().collectLatest { location ->
|
||||
currentLocation = location
|
||||
saveLastLocation(location)
|
||||
updateNearestShelters(location)
|
||||
|
||||
// Center map on first location fix
|
||||
|
|
@ -439,7 +437,6 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
}
|
||||
|
||||
updateSelectedShelterUI()
|
||||
ShelterWidgetProvider.requestUpdate(this)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -745,15 +742,6 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
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 {
|
||||
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
?: return false
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -20,7 +20,6 @@
|
|||
<!-- Tilfluktsrominfo -->
|
||||
<string name="shelter_capacity">%d plasser</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>
|
||||
|
||||
<!-- Handlinger -->
|
||||
|
|
@ -46,13 +45,6 @@
|
|||
<string name="update_success">Tilfluktsromdata oppdatert</string>
|
||||
<string name="update_failed">Oppdatering mislyktes — bruker lagrede data</string>
|
||||
|
||||
<!-- Widget -->
|
||||
<string name="widget_description">Viser n\u00e6rmeste tilfluktsrom med avstand</string>
|
||||
<string name="widget_open_app">\u00c5pne appen for posisjon</string>
|
||||
<string name="widget_no_data">Ingen tilfluktsromdata</string>
|
||||
<string name="widget_no_location">Trykk for \u00e5 oppdatere</string>
|
||||
<string name="widget_updated_at">Oppdatert %s</string>
|
||||
|
||||
<!-- Dataferskhet -->
|
||||
<string name="freshness_fresh">Data er oppdatert</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_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_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="action_about">Om denne appen</string>
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@
|
|||
<!-- Tilfluktsrominfo -->
|
||||
<string name="shelter_capacity">%d plassar</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>
|
||||
|
||||
<!-- Handlingar -->
|
||||
|
|
@ -46,13 +45,6 @@
|
|||
<string name="update_success">Tilfluktsromdata oppdatert</string>
|
||||
<string name="update_failed">Oppdatering mislukkast — brukar lagra data</string>
|
||||
|
||||
<!-- Widget -->
|
||||
<string name="widget_description">Viser n\u00e6raste tilfluktsrom med avstand</string>
|
||||
<string name="widget_open_app">Opne appen for posisjon</string>
|
||||
<string name="widget_no_data">Ingen tilfluktsromdata</string>
|
||||
<string name="widget_no_location">Trykk for \u00e5 oppdatere</string>
|
||||
<string name="widget_updated_at">Oppdatert %s</string>
|
||||
|
||||
<!-- Dataferskheit -->
|
||||
<string name="freshness_fresh">Data er oppdatert</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_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_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="action_about">Om denne appen</string>
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@
|
|||
<!-- Shelter info -->
|
||||
<string name="shelter_capacity">%d places</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>
|
||||
|
||||
<!-- Actions -->
|
||||
|
|
@ -46,13 +45,6 @@
|
|||
<string name="update_success">Shelter data updated</string>
|
||||
<string name="update_failed">Update failed — using cached data</string>
|
||||
|
||||
<!-- Widget -->
|
||||
<string name="widget_description">Shows nearest shelter with distance</string>
|
||||
<string name="widget_open_app">Open app for location</string>
|
||||
<string name="widget_no_data">No shelter data</string>
|
||||
<string name="widget_no_location">Tap to refresh</string>
|
||||
<string name="widget_updated_at">Updated %s</string>
|
||||
|
||||
<!-- Data freshness -->
|
||||
<string name="freshness_fresh">Data is up to date</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_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_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="action_about">About this app</string>
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ Features:
|
|||
• Compass navigation — direction arrow points to the selected shelter
|
||||
• Offline map — map tiles are cached automatically for use without internet
|
||||
• 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
|
||||
• Civil defense info — what to do if the alarm sounds
|
||||
• Multilingual — English, Bokmål, and Nynorsk
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ Funksjoner:
|
|||
• Kompassnavigasjon — retningspil som peker mot valgt tilfluktsrom
|
||||
• Frakoblet kart — kartfliser lagres automatisk for bruk uten nett
|
||||
• 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
|
||||
• Sivilforsvarsinformasjon — hva du skal gjøre hvis alarmen går
|
||||
• Flerspråklig — engelsk, bokmål og nynorsk
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ Funksjonar:
|
|||
• Kompassnavigasjon — retningspil som peikar mot valt tilfluktsrom
|
||||
• Fråkopla kart — kartfliser lagrast automatisk for bruk utan nett
|
||||
• 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
|
||||
• Sivilforsvarsinformasjon — kva du skal gjere om alarmen går
|
||||
• Fleirspråkleg — engelsk, bokmål og nynorsk
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue