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:
parent
3c49dbdcde
commit
e61f503c81
4 changed files with 347 additions and 36 deletions
|
|
@ -363,6 +363,7 @@ 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
|
||||||
|
|
@ -707,6 +708,15 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
||||||
binding.loadingOverlay.visibility = View.GONE
|
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 {
|
private fun isNetworkAvailable(): Boolean {
|
||||||
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||||
?: return false
|
?: return false
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,23 @@ import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.location.Location
|
import android.location.Location
|
||||||
import android.location.LocationManager
|
import android.location.LocationManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.CancellationSignal
|
||||||
|
import android.text.format.DateFormat
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.widget.RemoteViews
|
import android.widget.RemoteViews
|
||||||
import androidx.core.content.ContextCompat
|
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.MainActivity
|
||||||
import no.naiv.tilfluktsrom.R
|
import no.naiv.tilfluktsrom.R
|
||||||
import no.naiv.tilfluktsrom.data.ShelterDatabase
|
import no.naiv.tilfluktsrom.data.ShelterDatabase
|
||||||
import no.naiv.tilfluktsrom.location.ShelterFinder
|
import no.naiv.tilfluktsrom.location.ShelterFinder
|
||||||
import no.naiv.tilfluktsrom.util.DistanceUtils
|
import no.naiv.tilfluktsrom.util.DistanceUtils
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Home screen widget showing the nearest shelter with distance.
|
* 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
|
* - Live: MainActivity sends ACTION_REFRESH on each GPS location update
|
||||||
* - Manual: user taps the refresh button on the widget
|
* - 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
|
* Note: Background processes cannot reliably trigger GPS hardware on
|
||||||
* BroadcastReceiver context makes FusedLocationProviderClient setup awkward.
|
* Android 8+. The SharedPreferences fallback ensures the widget works
|
||||||
* For a one-shot getLastKnownLocation, LocationManager is equally effective.
|
* after app updates and reboots without opening the app first.
|
||||||
*/
|
*/
|
||||||
class ShelterWidgetProvider : AppWidgetProvider() {
|
class ShelterWidgetProvider : AppWidgetProvider() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "ShelterWidget"
|
private const val TAG = "ShelterWidget"
|
||||||
const val ACTION_REFRESH = "no.naiv.tilfluktsrom.widget.REFRESH"
|
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). */
|
/** Trigger a widget refresh from anywhere (e.g. MainActivity on location update). */
|
||||||
fun requestUpdate(context: Context) {
|
fun requestUpdate(context: Context) {
|
||||||
|
|
@ -47,6 +61,16 @@ class ShelterWidgetProvider : AppWidgetProvider() {
|
||||||
}
|
}
|
||||||
context.sendBroadcast(intent)
|
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) {
|
override fun onEnabled(context: Context) {
|
||||||
|
|
@ -64,29 +88,55 @@ class ShelterWidgetProvider : AppWidgetProvider() {
|
||||||
appWidgetManager: AppWidgetManager,
|
appWidgetManager: AppWidgetManager,
|
||||||
appWidgetIds: IntArray
|
appWidgetIds: IntArray
|
||||||
) {
|
) {
|
||||||
for (appWidgetId in appWidgetIds) {
|
WidgetUpdateWorker.schedule(context)
|
||||||
updateWidget(context, appWidgetManager, appWidgetId)
|
updateAllWidgetsAsync(context, null)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
super.onReceive(context, intent)
|
super.onReceive(context, intent)
|
||||||
|
|
||||||
if (intent.action == ACTION_REFRESH) {
|
if (intent.action == ACTION_REFRESH) {
|
||||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
val providedLocation = if (intent.hasExtra(EXTRA_LATITUDE)) {
|
||||||
val widgetIds = appWidgetManager.getAppWidgetIds(
|
Location("widget").apply {
|
||||||
ComponentName(context, ShelterWidgetProvider::class.java)
|
latitude = intent.getDoubleExtra(EXTRA_LATITUDE, 0.0)
|
||||||
)
|
longitude = intent.getDoubleExtra(EXTRA_LONGITUDE, 0.0)
|
||||||
for (appWidgetId in widgetIds) {
|
}
|
||||||
updateWidget(context, appWidgetManager, appWidgetId)
|
} 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(
|
private fun updateWidget(
|
||||||
context: Context,
|
context: Context,
|
||||||
appWidgetManager: AppWidgetManager,
|
appWidgetManager: AppWidgetManager,
|
||||||
appWidgetId: Int
|
appWidgetId: Int,
|
||||||
|
location: Location?
|
||||||
) {
|
) {
|
||||||
val views = RemoteViews(context.packageName, R.layout.widget_nearest_shelter)
|
val views = RemoteViews(context.packageName, R.layout.widget_nearest_shelter)
|
||||||
|
|
||||||
|
|
@ -106,20 +156,18 @@ class ShelterWidgetProvider : AppWidgetProvider() {
|
||||||
)
|
)
|
||||||
views.setOnClickPendingIntent(R.id.widgetRefreshButton, refreshPending)
|
views.setOnClickPendingIntent(R.id.widgetRefreshButton, refreshPending)
|
||||||
|
|
||||||
// Check location permission
|
// Check permission
|
||||||
if (ContextCompat.checkSelfPermission(
|
if (ContextCompat.checkSelfPermission(
|
||||||
context, Manifest.permission.ACCESS_FINE_LOCATION
|
context, Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
) != PackageManager.PERMISSION_GRANTED
|
) != 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)
|
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get last known location from LocationManager
|
|
||||||
val location = getLastKnownLocation(context)
|
|
||||||
if (location == null) {
|
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)
|
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -127,14 +175,14 @@ class ShelterWidgetProvider : AppWidgetProvider() {
|
||||||
// Query shelters from Room (fast: ~556 rows, <10ms)
|
// Query shelters from Room (fast: ~556 rows, <10ms)
|
||||||
val shelters = try {
|
val shelters = try {
|
||||||
val dao = ShelterDatabase.getInstance(context).shelterDao()
|
val dao = ShelterDatabase.getInstance(context).shelterDao()
|
||||||
runBlocking { dao.getAllSheltersList() }
|
kotlinx.coroutines.runBlocking { dao.getAllSheltersList() }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to query shelters", e)
|
Log.e(TAG, "Failed to query shelters", e)
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shelters.isEmpty()) {
|
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)
|
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -145,7 +193,7 @@ class ShelterWidgetProvider : AppWidgetProvider() {
|
||||||
).firstOrNull()
|
).firstOrNull()
|
||||||
|
|
||||||
if (nearest == null) {
|
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)
|
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -160,29 +208,138 @@ class ShelterWidgetProvider : AppWidgetProvider() {
|
||||||
R.id.widgetDistance,
|
R.id.widgetDistance,
|
||||||
DistanceUtils.formatDistance(nearest.distanceMeters)
|
DistanceUtils.formatDistance(nearest.distanceMeters)
|
||||||
)
|
)
|
||||||
|
views.setTextViewText(R.id.widgetTimestamp, formatTimestamp(context))
|
||||||
|
|
||||||
appWidgetManager.updateAppWidget(appWidgetId, views)
|
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Show a fallback message when location or data is unavailable. */
|
/** 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.widgetAddress, message)
|
||||||
views.setTextViewText(R.id.widgetDetails, "")
|
views.setTextViewText(R.id.widgetDetails, "")
|
||||||
views.setTextViewText(R.id.widgetDistance, "")
|
views.setTextViewText(R.id.widgetDistance, "")
|
||||||
|
views.setTextViewText(R.id.widgetTimestamp, formatTimestamp(context))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the best last known location from GPS and Network providers. */
|
/** Format current time as HH:mm, respecting the user's 12/24h preference. */
|
||||||
private fun getLastKnownLocation(context: Context): Location? {
|
private fun formatTimestamp(context: Context): String {
|
||||||
val locationManager = context.getSystemService(Context.LOCATION_SERVICE)
|
val format = DateFormat.getTimeFormat(context)
|
||||||
as? LocationManager ?: return null
|
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 {
|
return try {
|
||||||
val lastGps = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
|
val client = LocationServices.getFusedLocationProviderClient(context)
|
||||||
val lastNetwork = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)
|
// Try cache first (instant)
|
||||||
listOfNotNull(lastGps, lastNetwork).maxByOrNull { it.time }
|
val cached = Tasks.await(client.lastLocation, 3, TimeUnit.SECONDS)
|
||||||
} catch (e: SecurityException) {
|
if (cached != null) return cached
|
||||||
Log.e(TAG, "SecurityException getting last known location", e)
|
// 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
|
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,31 +1,51 @@
|
||||||
package no.naiv.tilfluktsrom.widget
|
package no.naiv.tilfluktsrom.widget
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.content.Context
|
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.CoroutineWorker
|
||||||
import androidx.work.ExistingPeriodicWorkPolicy
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.PeriodicWorkRequestBuilder
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import androidx.work.WorkerParameters
|
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 java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Periodic background worker that refreshes the home screen widget.
|
* Periodic background worker that refreshes the home screen widget.
|
||||||
*
|
*
|
||||||
* Scheduled every 15 minutes (WorkManager's minimum interval).
|
* 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(
|
class WidgetUpdateWorker(
|
||||||
context: Context,
|
context: Context,
|
||||||
params: WorkerParameters
|
params: WorkerParameters
|
||||||
) : CoroutineWorker(context, params) {
|
) : CoroutineWorker(context, params) {
|
||||||
|
|
||||||
override suspend fun doWork(): Result {
|
|
||||||
ShelterWidgetProvider.requestUpdate(applicationContext)
|
|
||||||
return Result.success()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val TAG = "WidgetUpdateWorker"
|
||||||
private const val WORK_NAME = "widget_update"
|
private const val WORK_NAME = "widget_update"
|
||||||
|
private const val LOCATION_TIMEOUT_MS = 10_000L
|
||||||
|
|
||||||
/** Schedule periodic widget updates. Safe to call multiple times. */
|
/** Schedule periodic widget updates. Safe to call multiple times. */
|
||||||
fun schedule(context: Context) {
|
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). */
|
/** Cancel periodic updates (e.g. when all widgets are removed). */
|
||||||
fun cancel(context: Context) {
|
fun cancel(context: Context) {
|
||||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,14 @@
|
||||||
android:textColor="@color/text_secondary"
|
android:textColor="@color/text_secondary"
|
||||||
android:textSize="12sp"
|
android:textSize="12sp"
|
||||||
tools:text="400 places" />
|
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>
|
</LinearLayout>
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue