From ff4b3245f55980d341169559da073624bebe6886 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Tue, 10 Mar 2026 20:25:47 +0100 Subject: [PATCH] Legg til fdroid-byggvariant uten Google Play Services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splitt LocationProvider, ShelterWidgetProvider og WidgetUpdateWorker i to varianter: standard (med Play Services for bedre GPS) og fdroid (kun AOSP LocationManager). Play Services-avhengigheten er nå begrenset til standardImplementation. Begge varianter bygger og har identisk funksjonalitet — fdroid-varianten mangler bare FusedLocationProviderClient som en ekstra lokasjonskilde. Co-Authored-By: Claude Opus 4.6 --- app/build.gradle.kts | 14 +- .../tilfluktsrom/location/LocationProvider.kt | 119 ++++++++ .../widget/ShelterWidgetProvider.kt | 265 ++++++++++++++++++ .../tilfluktsrom/widget/WidgetUpdateWorker.kt | 133 +++++++++ .../tilfluktsrom/location/LocationProvider.kt | 0 .../widget/ShelterWidgetProvider.kt | 0 .../tilfluktsrom/widget/WidgetUpdateWorker.kt | 0 7 files changed, 529 insertions(+), 2 deletions(-) create mode 100644 app/src/fdroid/java/no/naiv/tilfluktsrom/location/LocationProvider.kt create mode 100644 app/src/fdroid/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt create mode 100644 app/src/fdroid/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt rename app/src/{main => standard}/java/no/naiv/tilfluktsrom/location/LocationProvider.kt (100%) rename app/src/{main => standard}/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt (100%) rename app/src/{main => standard}/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt (100%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d905c26..6270317 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -40,6 +40,16 @@ android { } } + flavorDimensions += "distribution" + productFlavors { + create("standard") { + dimension = "distribution" + } + create("fdroid") { + dimension = "distribution" + } + } + buildTypes { release { isMinifyEnabled = true @@ -88,8 +98,8 @@ dependencies { // OSMDroid (offline-capable OpenStreetMap) implementation("org.osmdroid:osmdroid-android:6.1.20") - // Google Play Services Location (precise GPS) - implementation("com.google.android.gms:play-services-location:21.3.0") + // 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") diff --git a/app/src/fdroid/java/no/naiv/tilfluktsrom/location/LocationProvider.kt b/app/src/fdroid/java/no/naiv/tilfluktsrom/location/LocationProvider.kt new file mode 100644 index 0000000..87e19af --- /dev/null +++ b/app/src/fdroid/java/no/naiv/tilfluktsrom/location/LocationProvider.kt @@ -0,0 +1,119 @@ +package no.naiv.tilfluktsrom.location + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.os.Bundle +import android.os.Looper +import android.util.Log +import androidx.core.content.ContextCompat +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +/** + * Provides GPS location updates using AOSP LocationManager. + * + * F-Droid flavor: no Google Play Services dependency. Uses GPS + Network providers + * directly via LocationManager (available on all Android 8.0+ devices). + */ +class LocationProvider(private val context: Context) { + + companion object { + private const val TAG = "LocationProvider" + private const val UPDATE_INTERVAL_MS = 5000L + } + + init { + Log.d(TAG, "Location backend: LocationManager (F-Droid build)") + } + + /** + * Stream of location updates. Emits the last known location first (if available), + * then continuous updates. Throws SecurityException if permission is not granted. + */ + fun locationUpdates(): Flow = callbackFlow { + if (!hasLocationPermission()) { + close(SecurityException("Location permission not granted")) + return@callbackFlow + } + + val locationManager = context.getSystemService(Context.LOCATION_SERVICE) + as? LocationManager + + if (locationManager == null) { + close(IllegalStateException("LocationManager not available")) + return@callbackFlow + } + + // Emit best last known location immediately (pick most recent of GPS/Network) + try { + val lastGps = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) + val lastNetwork = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER) + val best = listOfNotNull(lastGps, lastNetwork).maxByOrNull { it.time } + if (best != null) { + val result = trySend(best) + if (result.isFailure) { + Log.w(TAG, "Failed to emit last known location") + } + } + } catch (e: SecurityException) { + Log.e(TAG, "SecurityException getting last known location", e) + } + + // LocationListener compatible with API 26-28 (onStatusChanged required before API 29) + val listener = object : LocationListener { + override fun onLocationChanged(location: Location) { + val sendResult = trySend(location) + if (sendResult.isFailure) { + Log.w(TAG, "Failed to emit location update") + } + } + + // Required for API 26-28 compatibility (deprecated from API 29+) + @Deprecated("Deprecated in API 29") + override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {} + override fun onProviderEnabled(provider: String) {} + override fun onProviderDisabled(provider: String) {} + } + + try { + // Request from both providers: GPS is accurate, Network gives faster first fix + if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) { + locationManager.requestLocationUpdates( + LocationManager.GPS_PROVIDER, + UPDATE_INTERVAL_MS, + 0f, + listener, + Looper.getMainLooper() + ) + } + if (locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) { + locationManager.requestLocationUpdates( + LocationManager.NETWORK_PROVIDER, + UPDATE_INTERVAL_MS, + 0f, + listener, + Looper.getMainLooper() + ) + } + } catch (e: SecurityException) { + Log.e(TAG, "SecurityException requesting location updates", e) + close(e) + return@callbackFlow + } + + awaitClose { + locationManager.removeUpdates(listener) + } + } + + fun hasLocationPermission(): Boolean { + return ContextCompat.checkSelfPermission( + context, Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + } +} diff --git a/app/src/fdroid/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt b/app/src/fdroid/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt new file mode 100644 index 0000000..b144b01 --- /dev/null +++ b/app/src/fdroid/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt @@ -0,0 +1,265 @@ +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) + } + + 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() + } + } + + 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 + } +} diff --git a/app/src/fdroid/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt b/app/src/fdroid/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt new file mode 100644 index 0000000..1b83c09 --- /dev/null +++ b/app/src/fdroid/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt @@ -0,0 +1,133 @@ +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( + 15, TimeUnit.MINUTES + ).build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request + ) + } + + fun runOnce(context: Context) { + val request = OneTimeWorkRequestBuilder().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() + } + + 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() + } + } + + 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 { 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 + } + } +} diff --git a/app/src/main/java/no/naiv/tilfluktsrom/location/LocationProvider.kt b/app/src/standard/java/no/naiv/tilfluktsrom/location/LocationProvider.kt similarity index 100% rename from app/src/main/java/no/naiv/tilfluktsrom/location/LocationProvider.kt rename to app/src/standard/java/no/naiv/tilfluktsrom/location/LocationProvider.kt diff --git a/app/src/main/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt b/app/src/standard/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt similarity index 100% rename from app/src/main/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt rename to app/src/standard/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt diff --git a/app/src/main/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt b/app/src/standard/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt similarity index 100% rename from app/src/main/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt rename to app/src/standard/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt