Legg til fdroid-byggvariant uten Google Play Services

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 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-10 20:25:47 +01:00
commit ff4b3245f5
7 changed files with 529 additions and 2 deletions

View file

@ -40,6 +40,16 @@ android {
} }
} }
flavorDimensions += "distribution"
productFlavors {
create("standard") {
dimension = "distribution"
}
create("fdroid") {
dimension = "distribution"
}
}
buildTypes { buildTypes {
release { release {
isMinifyEnabled = true isMinifyEnabled = true
@ -88,8 +98,8 @@ dependencies {
// OSMDroid (offline-capable OpenStreetMap) // OSMDroid (offline-capable OpenStreetMap)
implementation("org.osmdroid:osmdroid-android:6.1.20") implementation("org.osmdroid:osmdroid-android:6.1.20")
// Google Play Services Location (precise GPS) // Google Play Services Location (precise GPS) — standard flavor only
implementation("com.google.android.gms:play-services-location:21.3.0") "standardImplementation"("com.google.android.gms:play-services-location:21.3.0")
// WorkManager (periodic widget updates) // WorkManager (periodic widget updates)
implementation("androidx.work:work-runtime-ktx:2.9.1") implementation("androidx.work:work-runtime-ktx:2.9.1")

View file

@ -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<Location> = 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
}
}

View file

@ -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
}
}

View file

@ -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<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()
}
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<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
}
}
}