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 { buildTypes {
release { release {
// Sign with the real release keystore when key.properties is present, // Sign with the real release keystore when key.properties is present,

View file

@ -1,5 +1,172 @@
package org.comunes.tane 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.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, getIt.isRegistered<SocialService>() ? getIt<SocialService>() : null,
socialSettings: getIt<SocialSettings>(), socialSettings: getIt<SocialSettings>(),
connection: connection, connection: connection,
location: const GeolocatorCoarseLocation(), location: const NativeCoarseLocation(),
outbox: getIt<OfferOutbox>(), outbox: getIt<OfferOutbox>(),
messageStore: getIt<MessageStore>(), messageStore: getIt<MessageStore>(),
profileStore: getIt<ProfileStore>(), 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 /// 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 /// 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. /// the result to a low-precision geohash a precise fix never leaves the device.
abstract interface class CoarseLocationProvider { abstract interface class CoarseLocationProvider {
Future<({double lat, double lon})?> currentCoarseLatLon(); Future<({double lat, double lon})?> currentCoarseLatLon();
} }
/// [geolocator]-backed implementation. Requests low accuracy only, and returns /// Platform-channel implementation backed by Android's own LocationManager —
/// null on any denial/error rather than throwing, so the UI can degrade quietly. /// deliberately no Google Play Services, so the APK stays free of proprietary
class GeolocatorCoarseLocation implements CoarseLocationProvider { /// classes (F-Droid inclusion). The native side handles the permission prompt,
const GeolocatorCoarseLocation(); /// 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 @override
Future<({double lat, double lon})?> currentCoarseLatLon() async { Future<({double lat, double lon})?> currentCoarseLatLon() async {
if (defaultTargetPlatform != TargetPlatform.android) return null;
try { try {
var permission = await Geolocator.checkPermission(); // Safety net over the native 12s fix timeout; the permission prompt can
if (permission == LocationPermission.denied) { // legitimately keep the call open for a while, hence the wide margin.
permission = await Geolocator.requestPermission(); final result = await _channel
} .invokeMapMethod<String, double>('getCoarseLatLon')
// Denied (or turned off): return null quietly the UI shows an actionable .timeout(const Duration(seconds: 60));
// message. We don't yank the user into system settings. final lat = result?['lat'];
if (permission == LocationPermission.denied || final lon = result?['lon'];
permission == LocationPermission.deniedForever) { if (lat == null || lon == null) return null;
return null; return (lat: lat, lon: lon);
}
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);
} catch (_) { } catch (_) {
return null; // plugin unsupported (desktop), etc. return null; // denied, timeout, channel error
} }
} }
} }

View file

@ -1,7 +1,7 @@
name: tane name: tane
description: "Tane — local-first, encrypted, decentralized traditional-seed inventory and market." description: "Tane — local-first, encrypted, decentralized traditional-seed inventory and market."
publish_to: 'none' publish_to: 'none'
version: 0.1.0+2 version: 0.1.1+3
environment: environment:
sdk: ^3.11.5 sdk: ^3.11.5
@ -67,10 +67,10 @@ dependencies:
material_symbols_icons: ^4.2951.0 material_symbols_icons: ^4.2951.0
url_launcher: ^6.3.2 url_launcher: ^6.3.2
package_info_plus: ^9.0.1 package_info_plus: ^9.0.1
# Optional coarse device location (BSD-3) to set the sharing area without # Optional coarse device location goes through a small platform channel to
# typing a code. Low accuracy only; the value is immediately reduced to a # Android's own LocationManager (lib/services/coarse_location.dart) — no
# low-precision geohash. Behind an interface so it's fakeable and pluggable. # location plugin, and deliberately no Google Play Services, so the APK
geolocator: ^13.0.1 # stays free of proprietary classes (F-Droid).
# Network reachability (BSD-3) to show a global "you're offline" banner — the # Network reachability (BSD-3) to show a global "you're offline" banner — the
# social layer needs a connection; the inventory works regardless. # social layer needs a connection; the inventory works regardless.
connectivity_plus: ^6.1.0 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 <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <file_selector_windows/file_selector_windows.h> #include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.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 <sqlcipher_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h> #include <url_launcher_windows/url_launcher_windows.h>
@ -20,8 +19,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FileSelectorWindows")); registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar( FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
GeolocatorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("GeolocatorWindows"));
Sqlite3FlutterLibsPluginRegisterWithRegistrar( Sqlite3FlutterLibsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar( UrlLauncherWindowsRegisterWithRegistrar(

View file

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

View file

@ -567,54 +567,6 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
geolocator:
dependency: transitive
description:
name: geolocator
sha256: f62bcd90459e63210bbf9c35deb6a51c521f992a78de19a1fe5c11704f9530e2
url: "https://pub.dev"
source: hosted
version: "13.0.4"
geolocator_android:
dependency: transitive
description:
name: geolocator_android
sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d
url: "https://pub.dev"
source: hosted
version: "4.6.2"
geolocator_apple:
dependency: transitive
description:
name: geolocator_apple
sha256: "853803d6bb1713c094e935b4a5ae5f19c0308acf81da13fa9ff84fb4c70c0b73"
url: "https://pub.dev"
source: hosted
version: "2.3.14"
geolocator_platform_interface:
dependency: transitive
description:
name: geolocator_platform_interface
sha256: cdb082e4f048b69da244117b7914cc60d2a8897546ffaa4f2529c786ded7aee2
url: "https://pub.dev"
source: hosted
version: "4.2.8"
geolocator_web:
dependency: transitive
description:
name: geolocator_web
sha256: "19e485a0f8d6a88abcf9c53cba3a4105e14b7435ed8ac1c108c067b938fe8429"
url: "https://pub.dev"
source: hosted
version: "4.1.4"
geolocator_windows:
dependency: transitive
description:
name: geolocator_windows
sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6"
url: "https://pub.dev"
source: hosted
version: "0.2.5"
get_it: get_it:
dependency: transitive dependency: transitive
description: description: