diff --git a/apps/app_seeds/android/app/build.gradle.kts b/apps/app_seeds/android/app/build.gradle.kts index 23baf51..abe879a 100644 --- a/apps/app_seeds/android/app/build.gradle.kts +++ b/apps/app_seeds/android/app/build.gradle.kts @@ -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, diff --git a/apps/app_seeds/android/app/src/main/kotlin/org/comunes/tane/MainActivity.kt b/apps/app_seeds/android/app/src/main/kotlin/org/comunes/tane/MainActivity.kt index ff137d4..029ac02 100644 --- a/apps/app_seeds/android/app/src/main/kotlin/org/comunes/tane/MainActivity.kt +++ b/apps/app_seeds/android/app/src/main/kotlin/org/comunes/tane/MainActivity.kt @@ -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, + 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 + } +} diff --git a/apps/app_seeds/lib/bootstrap.dart b/apps/app_seeds/lib/bootstrap.dart index 81aa9d9..6423bb0 100644 --- a/apps/app_seeds/lib/bootstrap.dart +++ b/apps/app_seeds/lib/bootstrap.dart @@ -86,7 +86,7 @@ class _BootstrapState extends State { getIt.isRegistered() ? getIt() : null, socialSettings: getIt(), connection: connection, - location: const GeolocatorCoarseLocation(), + location: const NativeCoarseLocation(), outbox: getIt(), messageStore: getIt(), profileStore: getIt(), diff --git a/apps/app_seeds/lib/services/coarse_location.dart b/apps/app_seeds/lib/services/coarse_location.dart index aa576ec..ff3b121 100644 --- a/apps/app_seeds/lib/services/coarse_location.dart +++ b/apps/app_seeds/lib/services/coarse_location.dart @@ -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('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… } } } diff --git a/apps/app_seeds/pubspec.yaml b/apps/app_seeds/pubspec.yaml index c8a721c..d88e309 100644 --- a/apps/app_seeds/pubspec.yaml +++ b/apps/app_seeds/pubspec.yaml @@ -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 diff --git a/apps/app_seeds/test/services/coarse_location_test.dart b/apps/app_seeds/test/services/coarse_location_test.dart new file mode 100644 index 0000000..5a0ccae --- /dev/null +++ b/apps/app_seeds/test/services/coarse_location_test.dart @@ -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); + }); +} diff --git a/apps/app_seeds/windows/flutter/generated_plugin_registrant.cc b/apps/app_seeds/windows/flutter/generated_plugin_registrant.cc index 8162265..4fed342 100644 --- a/apps/app_seeds/windows/flutter/generated_plugin_registrant.cc +++ b/apps/app_seeds/windows/flutter/generated_plugin_registrant.cc @@ -9,7 +9,6 @@ #include #include #include -#include #include #include @@ -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( diff --git a/apps/app_seeds/windows/flutter/generated_plugins.cmake b/apps/app_seeds/windows/flutter/generated_plugins.cmake index 3db1f1a..a16259e 100644 --- a/apps/app_seeds/windows/flutter/generated_plugins.cmake +++ b/apps/app_seeds/windows/flutter/generated_plugins.cmake @@ -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 ) diff --git a/pubspec.lock b/pubspec.lock index c5b37c4..e624d5f 100644 --- a/pubspec.lock +++ b/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: