diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ea06ac..736a321 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,18 +3,7 @@ All notable changes to Tane are recorded here. Human-readable, newest first. Dates are ISO (YYYY-MM-DD). -## [0.1.1] — 2026-07-17 - -### Changed -- **Fully Google-free build.** The optional "use my approximate location" - button (market setup) now talks to Android's own location service instead - of a plugin that embedded Google Play Services code. Same behavior — the - fix is still reduced on-device to a coarse area code — and the APK now - contains no proprietary classes, paving the way for F-Droid distribution. -- Release builds no longer embed Google's encrypted dependency-metadata - block in the signature. - -## [0.1.0] — 2026-07-16 — Block 1 beta +## [Unreleased] — Block 1 beta prep Block 1 is the offline, encrypted seed inventory — useful with zero network. diff --git a/apps/app_seeds/android/app/build.gradle.kts b/apps/app_seeds/android/app/build.gradle.kts index abe879a..23baf51 100644 --- a/apps/app_seeds/android/app/build.gradle.kts +++ b/apps/app_seeds/android/app/build.gradle.kts @@ -56,13 +56,6 @@ 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 029ac02..ff137d4 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,172 +1,5 @@ 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 -/** - * 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 - } -} +class MainActivity : FlutterActivity() diff --git a/apps/app_seeds/fastlane/metadata/android/en-US/changelogs/3.txt b/apps/app_seeds/fastlane/metadata/android/en-US/changelogs/3.txt deleted file mode 100644 index c8a5d44..0000000 --- a/apps/app_seeds/fastlane/metadata/android/en-US/changelogs/3.txt +++ /dev/null @@ -1 +0,0 @@ -The app is now fully Google-free: the optional "use my location" button uses Android's own location service, with no Google Play Services code in the app. Paves the way for F-Droid distribution. diff --git a/apps/app_seeds/fastlane/metadata/android/es-ES/changelogs/3.txt b/apps/app_seeds/fastlane/metadata/android/es-ES/changelogs/3.txt deleted file mode 100644 index d27c2cd..0000000 --- a/apps/app_seeds/fastlane/metadata/android/es-ES/changelogs/3.txt +++ /dev/null @@ -1 +0,0 @@ -La aplicación ya es totalmente libre de Google: el botón opcional «usar mi ubicación» usa el servicio de ubicación propio de Android, sin código de Google Play Services en la app. Allana el camino para su distribución en F-Droid. diff --git a/apps/app_seeds/lib/bootstrap.dart b/apps/app_seeds/lib/bootstrap.dart index 6423bb0..81aa9d9 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 NativeCoarseLocation(), + location: const GeolocatorCoarseLocation(), 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 ff3b121..aa576ec 100644 --- a/apps/app_seeds/lib/services/coarse_location.dart +++ b/apps/app_seeds/lib/services/coarse_location.dart @@ -1,40 +1,50 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; +import 'package:geolocator/geolocator.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 code stays at the composition root. The caller immediately reduces +/// platform plugin 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(); } -/// 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'); +/// [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(); @override Future<({double lat, double lon})?> currentCoarseLatLon() async { - if (defaultTargetPlatform != TargetPlatform.android) return null; try { - // 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); + 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); } catch (_) { - return null; // denied, timeout, channel error… + return null; // plugin unsupported (desktop), etc. } } } diff --git a/apps/app_seeds/pubspec.yaml b/apps/app_seeds/pubspec.yaml index d88e309..c8a721c 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.1+3 +version: 0.1.0+2 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 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). + # 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 # 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 deleted file mode 100644 index 5a0ccae..0000000 --- a/apps/app_seeds/test/services/coarse_location_test.dart +++ /dev/null @@ -1,59 +0,0 @@ -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 4fed342..8162265 100644 --- a/apps/app_seeds/windows/flutter/generated_plugin_registrant.cc +++ b/apps/app_seeds/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -19,6 +20,8 @@ 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 a16259e..3db1f1a 100644 --- a/apps/app_seeds/windows/flutter/generated_plugins.cmake +++ b/apps/app_seeds/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ 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/docs/fdroid/org.comunes.tane.yml b/docs/fdroid/org.comunes.tane.yml index a540845..141d9c7 100644 --- a/docs/fdroid/org.comunes.tane.yml +++ b/docs/fdroid/org.comunes.tane.yml @@ -37,29 +37,8 @@ Builds: - export PATH="/opt/flutter/bin:$PATH" - flutter build apk --release - - versionName: 0.1.1 - versionCode: 3 - commit: v0.1.1 - subdir: apps/app_seeds - sudo: - - apt-get update - - apt-get install -y git unzip xz-utils - - git clone --depth 1 -b 3.41.9 https://github.com/flutter/flutter.git /opt/flutter - - chown -R vagrant:vagrant /opt/flutter - output: build/app/outputs/flutter-apk/app-release.apk - prebuild: - - export PATH="/opt/flutter/bin:$PATH" - - git config --global --add safe.directory /opt/flutter - - flutter config --no-analytics - - flutter pub get - - dart run slang - - dart run build_runner build --delete-conflicting-outputs - build: - - export PATH="/opt/flutter/bin:$PATH" - - flutter build apk --release - AutoUpdateMode: Version UpdateCheckMode: Tags v[\d.]+ UpdateCheckData: apps/app_seeds/pubspec.yaml|version:\s*[\d.]+\+(\d+)|apps/app_seeds/pubspec.yaml|version:\s*([\d.]+)\+ -CurrentVersion: 0.1.1 -CurrentVersionCode: 3 +CurrentVersion: 0.1.0 +CurrentVersionCode: 2 diff --git a/pubspec.lock b/pubspec.lock index e624d5f..c5b37c4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -567,6 +567,54 @@ 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: