diff --git a/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt b/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt index bcc0cd0..7d64bc2 100644 --- a/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt @@ -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 diff --git a/app/src/main/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt b/app/src/main/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt index 62ba2d6..e13e054 100644 --- a/app/src/main/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt @@ -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 appWidgetManager = AppWidgetManager.getInstance(context) - val widgetIds = appWidgetManager.getAppWidgetIds( - ComponentName(context, ShelterWidgetProvider::class.java) - ) - for (appWidgetId in widgetIds) { - updateWidget(context, appWidgetManager, appWidgetId) - } + 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 + 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 + } + } } diff --git a/app/src/main/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt b/app/src/main/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt index 34529fd..f89993a 100644 --- a/app/src/main/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt @@ -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().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 { 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 + } + } } diff --git a/app/src/main/res/layout/widget_nearest_shelter.xml b/app/src/main/res/layout/widget_nearest_shelter.xml index 5ac2ba1..06e81bb 100644 --- a/app/src/main/res/layout/widget_nearest_shelter.xml +++ b/app/src/main/res/layout/widget_nearest_shelter.xml @@ -43,6 +43,14 @@ android:textColor="@color/text_secondary" android:textSize="12sp" tools:text="400 places" /> + +