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:
parent
7f1118a243
commit
d0fabb80a9
9 changed files with 264 additions and 93 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>(),
|
||||
|
|
|
|||
|
|
@ -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…
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
59
apps/app_seeds/test/services/coarse_location_test.dart
Normal file
59
apps/app_seeds/test/services/coarse_location_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
48
pubspec.lock
48
pubspec.lock
|
|
@ -567,54 +567,6 @@ packages:
|
|||
description: flutter
|
||||
source: sdk
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue