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

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