fix(android): drop geolocator for a native LocationManager channel

Removes the geolocator dependency, which transitively embedded
com.google.android.gms.* (play-services-location) — proprietary classes
that F-Droid's scanner rejects. The optional coarse-location button now
talks to Android's own LocationManager over a small platform channel
(org.comunes.tane/coarse_location): permission prompt, single coarse fix
with a 12s timeout, last-known fallback, null on any failure. Behavior
is unchanged and the CoarseLocationProvider interface is preserved, so
the UI, i18n and widget tests need no changes.

Also sets dependenciesInfo.includeInApk/Bundle = false so the AGP
dependency-metadata block (also flagged by F-Droid) is not embedded.
Neither change affects the Google Play build.
This commit is contained in:
vjrj 2026-07-17 09:19:05 +02:00
parent 7f1118a243
commit d0fabb80a9
9 changed files with 264 additions and 93 deletions

View file

@ -1,5 +1,172 @@
package org.comunes.tane
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.Build
import android.os.CancellationSignal
import android.os.Handler
import android.os.Looper
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity()
/**
* Hosts the "coarse location" platform channel backed by Android's own
* LocationManager. Deliberately no Google Play Services: the APK must stay
* free of proprietary classes (F-Droid). Mirrors the old plugin's behavior:
* ask permission if needed, try a single coarse fix with a timeout, fall
* back to the last known position, and reply null on any failure never
* throw across the channel.
*/
class MainActivity : FlutterActivity() {
private var pendingPermissionResult: MethodChannel.Result? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
"getCoarseLatLon" -> getCoarseLatLon(result)
else -> result.notImplemented()
}
}
}
private fun getCoarseLatLon(result: MethodChannel.Result) {
if (hasLocationPermission()) {
fetchCoarseLocation(result)
return
}
if (pendingPermissionResult != null) {
// A permission prompt is already on screen; don't stack requests.
result.success(null)
return
}
pendingPermissionResult = result
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION),
PERMISSION_REQUEST_CODE,
)
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray,
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode != PERMISSION_REQUEST_CODE) return
val result = pendingPermissionResult ?: return
pendingPermissionResult = null
if (hasLocationPermission()) {
fetchCoarseLocation(result)
} else {
result.success(null)
}
}
private fun hasLocationPermission(): Boolean =
ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_COARSE_LOCATION,
) == PackageManager.PERMISSION_GRANTED
private fun fetchCoarseLocation(result: MethodChannel.Result) {
val reply = SingleReply(result)
try {
val lm = getSystemService(Context.LOCATION_SERVICE) as LocationManager
val provider = pickProvider(lm)
if (provider == null) {
reply.send(lastKnownLocation(lm))
return
}
val handler = Handler(Looper.getMainLooper())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val cancel = CancellationSignal()
handler.postDelayed({
if (!reply.sent) {
cancel.cancel()
reply.send(lastKnownLocation(lm))
}
}, FIX_TIMEOUT_MS)
lm.getCurrentLocation(provider, cancel, mainExecutor) { location ->
reply.send(location ?: lastKnownLocation(lm))
}
} else {
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
reply.send(location)
}
@Deprecated("Deprecated in Java")
override fun onStatusChanged(
provider: String?,
status: Int,
extras: android.os.Bundle?,
) {}
override fun onProviderEnabled(provider: String) {}
override fun onProviderDisabled(provider: String) {}
}
handler.postDelayed({
if (!reply.sent) {
try {
lm.removeUpdates(listener)
} catch (_: Exception) {
}
reply.send(lastKnownLocation(lm))
}
}, FIX_TIMEOUT_MS)
@Suppress("DEPRECATION")
lm.requestSingleUpdate(provider, listener, Looper.getMainLooper())
}
} catch (_: Exception) {
// SecurityException, disabled providers, anything: degrade quietly.
reply.send(null)
}
}
/** Coarse first: the network provider is enough for a ~2 km geohash. */
private fun pickProvider(lm: LocationManager): String? = when {
lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER) ->
LocationManager.NETWORK_PROVIDER
lm.isProviderEnabled(LocationManager.GPS_PROVIDER) ->
LocationManager.GPS_PROVIDER
else -> null
}
private fun lastKnownLocation(lm: LocationManager): Location? = try {
lm.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)
?: lm.getLastKnownLocation(LocationManager.GPS_PROVIDER)
?: lm.getLastKnownLocation(LocationManager.PASSIVE_PROVIDER)
} catch (_: Exception) {
null
}
/** Answers a MethodChannel.Result exactly once (fix vs. timeout race). */
private class SingleReply(private val result: MethodChannel.Result) {
var sent = false
private set
fun send(location: Location?) {
if (sent) return
sent = true
result.success(
location?.let { mapOf("lat" to it.latitude, "lon" to it.longitude) },
)
}
}
private companion object {
const val CHANNEL = "org.comunes.tane/coarse_location"
const val PERMISSION_REQUEST_CODE = 4711
const val FIX_TIMEOUT_MS = 12_000L
}
}