Fiks widget som ikkje viser data utan å opne appen først

Widgeten kunne ikkje hente posisjonsdata i bakgrunnen fordi Android
avgrenser GPS-tilgang for bakgrunnsprosessar (BroadcastReceiver) frå
og med Android 8+. Både FusedLocationProviderClient og LocationManager
returnerte null umiddelbart ved aktiv førespurnad frå bakgrunnen.

Løysinga er å lagre siste GPS-posisjon til SharedPreferences kvar gong
MainActivity får ei ny posisjonsfiks. Widgeten og WidgetUpdateWorker
les denne som siste utveg etter at system-API-ar har feila.

Endringane i detalj:

Widget (ShelterWidgetProvider):
- Brukar goAsync() + bakgrunnstråd for asynkron oppdatering
- Prøver FusedLocationProvider (Play Services), så LocationManager
  (AOSP), og til slutt SharedPreferences som fallback
- Ny metode requestUpdateWithLocation() for å sende posisjon via
  intent extras (unngår separate posisjonsbuffertar)
- Viser tidsstempel (HH:mm) for å sjå når widgeten sist vart oppdatert

WidgetUpdateWorker:
- Aktivt ber om ny posisjonsfiks (getCurrentLocation) i staden for
  berre å lese passiv buffer
- Sender oppnådd posisjon direkte til widgeten via broadcast
- Fallback til SharedPreferences viss aktiv førespurnad feilar
- Ny runOnce()-metode for eingongsoppdatering

MainActivity:
- Lagrar GPS-posisjon til SharedPreferences ved kvar oppdatering
  (saveLastLocation) så widgeten kan bruke den i bakgrunnen

Relatert: #3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-08 23:08:32 +01:00
commit e61f503c81
4 changed files with 347 additions and 36 deletions

View file

@ -363,6 +363,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
try {
locationProvider.locationUpdates().collectLatest { location ->
currentLocation = location
saveLastLocation(location)
updateNearestShelters(location)
// Center map on first location fix
@ -707,6 +708,15 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
binding.loadingOverlay.visibility = View.GONE
}
/** 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

View file

@ -10,15 +10,23 @@ 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 kotlinx.coroutines.runBlocking
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.
@ -28,17 +36,23 @@ import no.naiv.tilfluktsrom.util.DistanceUtils
* - Live: MainActivity sends ACTION_REFRESH on each GPS location update
* - Manual: user taps the refresh button on the widget
*
* Tapping the widget body opens MainActivity.
* 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
*
* Uses LocationManager directly (not the hybrid LocationProvider) because
* BroadcastReceiver context makes FusedLocationProviderClient setup awkward.
* For a one-shot getLastKnownLocation, LocationManager is equally effective.
* 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) {
@ -47,6 +61,16 @@ class ShelterWidgetProvider : AppWidgetProvider() {
}
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) {
@ -64,29 +88,55 @@ class ShelterWidgetProvider : AppWidgetProvider() {
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
for (appWidgetId in appWidgetIds) {
updateWidget(context, appWidgetManager, appWidgetId)
}
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)
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
appWidgetId: Int,
location: Location?
) {
val views = RemoteViews(context.packageName, R.layout.widget_nearest_shelter)
@ -106,20 +156,18 @@ class ShelterWidgetProvider : AppWidgetProvider() {
)
views.setOnClickPendingIntent(R.id.widgetRefreshButton, refreshPending)
// Check location permission
// Check permission
if (ContextCompat.checkSelfPermission(
context, Manifest.permission.ACCESS_FINE_LOCATION
) != PackageManager.PERMISSION_GRANTED
) {
showFallback(views, context.getString(R.string.widget_open_app))
showFallback(context, views, context.getString(R.string.widget_open_app))
appWidgetManager.updateAppWidget(appWidgetId, views)
return
}
// Get last known location from LocationManager
val location = getLastKnownLocation(context)
if (location == null) {
showFallback(views, context.getString(R.string.widget_no_location))
showFallback(context, views, context.getString(R.string.widget_no_location))
appWidgetManager.updateAppWidget(appWidgetId, views)
return
}
@ -127,14 +175,14 @@ class ShelterWidgetProvider : AppWidgetProvider() {
// Query shelters from Room (fast: ~556 rows, <10ms)
val shelters = try {
val dao = ShelterDatabase.getInstance(context).shelterDao()
runBlocking { dao.getAllSheltersList() }
kotlinx.coroutines.runBlocking { dao.getAllSheltersList() }
} catch (e: Exception) {
Log.e(TAG, "Failed to query shelters", e)
emptyList()
}
if (shelters.isEmpty()) {
showFallback(views, context.getString(R.string.widget_no_data))
showFallback(context, views, context.getString(R.string.widget_no_data))
appWidgetManager.updateAppWidget(appWidgetId, views)
return
}
@ -145,7 +193,7 @@ class ShelterWidgetProvider : AppWidgetProvider() {
).firstOrNull()
if (nearest == null) {
showFallback(views, context.getString(R.string.widget_no_data))
showFallback(context, views, context.getString(R.string.widget_no_data))
appWidgetManager.updateAppWidget(appWidgetId, views)
return
}
@ -160,29 +208,138 @@ class ShelterWidgetProvider : AppWidgetProvider() {
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(views: RemoteViews, message: String) {
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))
}
/** Get the best last known location from GPS and Network providers. */
private fun getLastKnownLocation(context: Context): Location? {
val locationManager = context.getSystemService(Context.LOCATION_SERVICE)
as? LocationManager ?: return null
/** Format current time as HH:mm, respecting the user's 12/24h preference. */
private fun formatTimestamp(context: Context): String {
val format = DateFormat.getTimeFormat(context)
return format.format(System.currentTimeMillis())
}
/**
* 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. */
private fun getSavedLocation(context: Context): Location? {
val prefs = context.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE)
if (!prefs.contains("last_lat")) 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 lastGps = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
val lastNetwork = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)
listOfNotNull(lastGps, lastNetwork).maxByOrNull { it.time }
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException getting last known location", e)
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,31 +1,51 @@
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).
* Simply triggers the widget's existing update logic via broadcast.
* 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) {
override suspend fun doWork(): Result {
ShelterWidgetProvider.requestUpdate(applicationContext)
return Result.success()
}
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) {
@ -40,9 +60,125 @@ class WidgetUpdateWorker(
)
}
/** 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. */
private fun getSavedLocation(): Location? {
val prefs = applicationContext.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE)
if (!prefs.contains("last_lat")) 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

@ -43,6 +43,14 @@
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