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

@ -56,6 +56,13 @@ android {
}
}
dependenciesInfo {
// No Google-encrypted dependency-metadata block in the APK/AAB
// signature: F-Droid rejects it, and Play does not require it.
includeInApk = false
includeInBundle = false
}
buildTypes {
release {
// Sign with the real release keystore when key.properties is present,

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

View file

@ -86,7 +86,7 @@ class _BootstrapState extends State<Bootstrap> {
getIt.isRegistered<SocialService>() ? getIt<SocialService>() : null,
socialSettings: getIt<SocialSettings>(),
connection: connection,
location: const GeolocatorCoarseLocation(),
location: const NativeCoarseLocation(),
outbox: getIt<OfferOutbox>(),
messageStore: getIt<MessageStore>(),
profileStore: getIt<ProfileStore>(),

View file

@ -1,50 +1,40 @@
import 'package:geolocator/geolocator.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// Supplies the device's *approximate* location (or null when unavailable or
/// denied). Behind an interface so the UI is fakeable in tests and the concrete
/// platform plugin stays at the composition root. The caller immediately reduces
/// platform code stays at the composition root. The caller immediately reduces
/// the result to a low-precision geohash a precise fix never leaves the device.
abstract interface class CoarseLocationProvider {
Future<({double lat, double lon})?> currentCoarseLatLon();
}
/// [geolocator]-backed implementation. Requests low accuracy only, and returns
/// null on any denial/error rather than throwing, so the UI can degrade quietly.
class GeolocatorCoarseLocation implements CoarseLocationProvider {
const GeolocatorCoarseLocation();
/// Platform-channel implementation backed by Android's own LocationManager —
/// deliberately no Google Play Services, so the APK stays free of proprietary
/// classes (F-Droid inclusion). The native side handles the permission prompt,
/// a coarse single fix with a timeout, and a last-known-position fallback.
/// Returns null on any denial/error rather than throwing, so the UI can
/// degrade quietly. Android only; other platforms return null.
class NativeCoarseLocation implements CoarseLocationProvider {
const NativeCoarseLocation();
static const _channel = MethodChannel('org.comunes.tane/coarse_location');
@override
Future<({double lat, double lon})?> currentCoarseLatLon() async {
if (defaultTargetPlatform != TargetPlatform.android) return null;
try {
var permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
// Denied (or turned off): return null quietly the UI shows an actionable
// message. We don't yank the user into system settings.
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
return null;
}
if (!await Geolocator.isLocationServiceEnabled()) return null;
// A fresh coarse fix can take a while (or never come indoors); fall back
// to the last known position so the button stays responsive.
Position? position;
try {
position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.low,
timeLimit: Duration(seconds: 12),
),
);
} catch (_) {
position = await Geolocator.getLastKnownPosition();
}
if (position == null) return null;
return (lat: position.latitude, lon: position.longitude);
// Safety net over the native 12s fix timeout; the permission prompt can
// legitimately keep the call open for a while, hence the wide margin.
final result = await _channel
.invokeMapMethod<String, double>('getCoarseLatLon')
.timeout(const Duration(seconds: 60));
final lat = result?['lat'];
final lon = result?['lon'];
if (lat == null || lon == null) return null;
return (lat: lat, lon: lon);
} catch (_) {
return null; // plugin unsupported (desktop), etc.
return null; // denied, timeout, channel error
}
}
}

View file

@ -1,7 +1,7 @@
name: tane
description: "Tane — local-first, encrypted, decentralized traditional-seed inventory and market."
publish_to: 'none'
version: 0.1.0+2
version: 0.1.1+3
environment:
sdk: ^3.11.5
@ -67,10 +67,10 @@ dependencies:
material_symbols_icons: ^4.2951.0
url_launcher: ^6.3.2
package_info_plus: ^9.0.1
# Optional coarse device location (BSD-3) to set the sharing area without
# typing a code. Low accuracy only; the value is immediately reduced to a
# low-precision geohash. Behind an interface so it's fakeable and pluggable.
geolocator: ^13.0.1
# Optional coarse device location goes through a small platform channel to
# Android's own LocationManager (lib/services/coarse_location.dart) — no
# location plugin, and deliberately no Google Play Services, so the APK
# stays free of proprietary classes (F-Droid).
# Network reachability (BSD-3) to show a global "you're offline" banner — the
# social layer needs a connection; the inventory works regardless.
connectivity_plus: ^6.1.0

View file

@ -0,0 +1,59 @@
import 'package:tane/services/coarse_location.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('org.comunes.tane/coarse_location');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
tearDown(() {
messenger.setMockMethodCallHandler(channel, null);
debugDefaultTargetPlatformOverride = null;
});
test('returns the lat/lon the platform channel provides', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
messenger.setMockMethodCallHandler(channel, (call) async {
expect(call.method, 'getCoarseLatLon');
return {'lat': 41.39, 'lon': 2.16};
});
final loc = await const NativeCoarseLocation().currentCoarseLatLon();
expect(loc, isNotNull);
expect(loc!.lat, 41.39);
expect(loc.lon, 2.16);
});
test('returns null when the platform reports no fix (denied/off)', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
messenger.setMockMethodCallHandler(channel, (call) async => null);
expect(await const NativeCoarseLocation().currentCoarseLatLon(), isNull);
});
test('returns null instead of throwing on a channel error', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
messenger.setMockMethodCallHandler(channel, (call) async {
throw PlatformException(code: 'boom');
});
expect(await const NativeCoarseLocation().currentCoarseLatLon(), isNull);
});
test('returns null off Android without touching the channel', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
var invoked = false;
messenger.setMockMethodCallHandler(channel, (call) async {
invoked = true;
return {'lat': 1.0, 'lon': 1.0};
});
expect(await const NativeCoarseLocation().currentCoarseLatLon(), isNull);
expect(invoked, isFalse);
});
}

View file

@ -9,7 +9,6 @@
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <geolocator_windows/geolocator_windows.h>
#include <sqlcipher_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
@ -20,8 +19,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
GeolocatorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("GeolocatorWindows"));
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(

View file

@ -6,7 +6,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus
file_selector_windows
flutter_secure_storage_windows
geolocator_windows
sqlcipher_flutter_libs
url_launcher_windows
)