From cedbe1dd6c2cd25a1bddb30c3e95de66be64ce20 Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Sun, 8 Feb 2026 17:01:28 -0800 Subject: [PATCH] Implement sparse location logging feature and update related services --- lib/connector/meshcore_connector.dart | 26 +++ lib/connector/meshcore_protocol.dart | 20 ++ lib/main.dart | 7 +- lib/screens/map_screen.dart | 4 +- lib/screens/settings_screen.dart | 17 ++ lib/services/sparse_location_logger.dart | 192 ++++++++++++++++++ macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 140 ++++++++++++- pubspec.yaml | 2 + .../flutter/generated_plugin_registrant.cc | 6 + windows/flutter/generated_plugins.cmake | 2 + 11 files changed, 405 insertions(+), 13 deletions(-) create mode 100644 lib/services/sparse_location_logger.dart diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 5d6c7e6..c4ec879 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'dart:convert'; import 'package:crypto/crypto.dart' as crypto; +import 'package:geolocator_platform_interface/src/models/position.dart'; +import 'package:meshcore_open/services/sparse_location_logger.dart'; import 'package:pointycastle/export.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; @@ -130,6 +132,7 @@ class MeshCoreConnector extends ChangeNotifier { PathHistoryService? _pathHistoryService; AppSettingsService? _appSettingsService; BackgroundService? _backgroundService; + SparseLocationLogger? _sparseLocationLogger; final NotificationService _notificationService = NotificationService(); BleDebugLogService? _bleDebugLogService; AppDebugLogService? _appDebugLogService; @@ -502,6 +505,7 @@ class MeshCoreConnector extends ChangeNotifier { BleDebugLogService? bleDebugLogService, AppDebugLogService? appDebugLogService, BackgroundService? backgroundService, + SparseLocationLogger? sparseLocationLogger, }) { _retryService = retryService; _pathHistoryService = pathHistoryService; @@ -509,11 +513,14 @@ class MeshCoreConnector extends ChangeNotifier { _bleDebugLogService = bleDebugLogService; _appDebugLogService = appDebugLogService; _backgroundService = backgroundService; + _sparseLocationLogger = sparseLocationLogger; // Initialize notification service _notificationService.initialize(); _loadChannelOrder(); + _sparseLocationLogger?.initialize(_updateLocationandAdvert); + // Initialize retry service callbacks _retryService?.initialize( sendMessageCallback: _sendMessageDirect, @@ -828,6 +835,8 @@ class MeshCoreConnector extends ChangeNotifier { return result; } + SparseLocationLogger? get sparseLocationLogger => _sparseLocationLogger; + bool get _shouldAutoReconnect => !_manualDisconnect && _lastDeviceId != null; void _cancelReconnectTimer() { @@ -3285,6 +3294,23 @@ class MeshCoreConnector extends ChangeNotifier { super.dispose(); } + + _updateLocationandAdvert(Position position) async { + double lat = position.latitude; + double lon = position.longitude; + + if (lat == 0.0 && lon == 0.0) { + // Invalid location + return; + } + lat = double.parse(lat.toStringAsFixed(3)) - 0.00015; + lon = double.parse(lon.toStringAsFixed(3)) - 0.00015; + print('Updating location to lat: $lat, lon: $lon'); + await sendFrame(buildSetOtherParamsFrame(true, 0, 1, 0)); + await setNodeLocation(lat: lat, lon: lon); + await sendSelfAdvert(flood: true); + await sendFrame(buildDeviceQueryFrame()); + } } const int _phRouteMask = 0x03; diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index 25359a8..12e39fe 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -151,6 +151,7 @@ const int cmdGetContactByKey = 30; const int cmdGetChannel = 31; const int cmdSetChannel = 32; const int cmdSendTracePath = 36; +const int cmdSetOtherParams = 38; const int cmdGetRadioSettings = 57; const int cmdGetTelemetryReq = 39; const int cmdGetCustomVar = 40; @@ -777,3 +778,22 @@ Uint8List buildZeroHopContact(Uint8List pubKey) { writer.writeBytes(pubKey); return writer.toBytes(); } + +// Build CMD_SET_OTHER_PARAMS frame +// Format: [cmd][allowAutoAddContacts][allowTelemetryFlags][advert_loc_policy][multi_acks] +Uint8List buildSetOtherParamsFrame( + bool allowAutoAddContacts, + int allowTelemetryFlags, + int advert_loc_policy, + int multi_acks, +) { + final writer = BufferWriter(); + writer.writeByte(cmdSetOtherParams); + writer.writeByte( + allowAutoAddContacts ? 0x01 : 0x00, + ); // Allow Auto Add Contacts + writer.writeByte(allowTelemetryFlags); // Allow Telemetry Flags + writer.writeByte(advert_loc_policy); // Advertisement Location Policy + writer.writeByte(multi_acks); // Multi Acknowledgements + return writer.toBytes(); +} diff --git a/lib/main.dart b/lib/main.dart index 8ee0ca4..b9a42e5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:meshcore_open/services/sparse_location_logger.dart'; import 'l10n/app_localizations.dart'; import 'package:provider/provider.dart'; @@ -33,7 +34,7 @@ void main() async { final appDebugLogService = AppDebugLogService(); final backgroundService = BackgroundService(); final mapTileCacheService = MapTileCacheService(); - + final sparseLocationLogger = SparseLocationLogger(); // Load settings await appSettingsService.loadSettings(); @@ -56,6 +57,7 @@ void main() async { bleDebugLogService: bleDebugLogService, appDebugLogService: appDebugLogService, backgroundService: backgroundService, + sparseLocationLogger: sparseLocationLogger, ); await connector.loadContactCache(); @@ -76,6 +78,7 @@ void main() async { bleDebugLogService: bleDebugLogService, appDebugLogService: appDebugLogService, mapTileCacheService: mapTileCacheService, + sparseLocationLogger: sparseLocationLogger, ), ); } @@ -89,6 +92,7 @@ class MeshCoreApp extends StatelessWidget { final BleDebugLogService bleDebugLogService; final AppDebugLogService appDebugLogService; final MapTileCacheService mapTileCacheService; + final SparseLocationLogger sparseLocationLogger; const MeshCoreApp({ super.key, @@ -100,6 +104,7 @@ class MeshCoreApp extends StatelessWidget { required this.bleDebugLogService, required this.appDebugLogService, required this.mapTileCacheService, + required this.sparseLocationLogger, }); @override diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index bc213f9..ad20b59 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -357,8 +357,8 @@ class _MapScreenState extends State { connector.selfLatitude!, connector.selfLongitude!, ), - width: 35, - height: 35, + width: 40, + height: 40, child: Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 4943284..d286a39 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -3,6 +3,7 @@ import 'package:meshcore_open/utils/gpx_export.dart'; import 'package:meshcore_open/widgets/elements_ui.dart'; import 'package:provider/provider.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:meshcore_open/services/sparse_location_logger.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; @@ -490,6 +491,8 @@ class _SettingsScreenState extends State { final latController = TextEditingController(); final lonController = TextEditingController(); final intervalController = TextEditingController(); + bool isLogging = connector.sparseLocationLogger?.isLogging() ?? false; + latController.text = connector.selfLatitude?.toStringAsFixed(6) ?? ''; lonController.text = connector.selfLongitude?.toStringAsFixed(6) ?? ''; @@ -534,6 +537,20 @@ class _SettingsScreenState extends State { signed: true, ), ), + const SizedBox(height: 16), + FeatureToggleRow( + title: "GPS Logging", + subtitle: "Enable GPS logging on the device", + value: isLogging, + onChanged: (value) async { + setDialogState(() => isLogging = value); + if (value) { + await connector.sparseLocationLogger?.startLogging(); + } else { + await connector.sparseLocationLogger?.stopLogging(); + } + }, + ), if (hasGPS) ...[ const SizedBox(height: 16), TextField( diff --git a/lib/services/sparse_location_logger.dart b/lib/services/sparse_location_logger.dart new file mode 100644 index 0000000..08aacd9 --- /dev/null +++ b/lib/services/sparse_location_logger.dart @@ -0,0 +1,192 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math' as math; + +import 'package:geolocator/geolocator.dart'; +import 'package:gpx/gpx.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:share_plus/share_plus.dart'; + +class SparseLocationLogger { + static const double distanceThresholdMiles = 0.25; + static const double distanceThresholdMeters = + distanceThresholdMiles * 1609.34; + static const double headingChangeThresholdDeg = 35.0; + static const double minSpeedForTurnKmh = 8.0; + static const double minTime = 120.0; // seconds + + Position? _lastLoggedPosition; + double? _lastHeading; + DateTime? _lastLoggedTime; + StreamSubscription? _positionStream; + Timer? _timer; + Function(Position position)? _onNewLogPoint; + // GPX structures + final Gpx _gpx = Gpx(); + Trkseg _currentSegment = Trkseg(); // one segment for the whole session + + File? _gpxFile; + + bool _isInitialized = false; + + void initialize(Function(Position position) onNewLogPoint) { + _onNewLogPoint = onNewLogPoint; + } + + Future startLogging() async { + // Permissions & service check (same as before) + var status = await Permission.location.request(); + if (!status.isGranted) { + print('Location permission denied'); + return; + } + + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + print('Location services disabled'); + return; + } + + // Prepare files + final directory = await getApplicationDocumentsDirectory(); + final timestamp = DateTime.now() + .toIso8601String() + .replaceAll(':', '-') + .split('.') + .first; + _gpxFile = File('${directory.path}/track_$timestamp.gpx'); + + // Init GPX metadata + _gpx.metadata = Metadata( + name: 'Sparse Track ${DateTime.now().toString().split(' ').first}', + desc: 'Sparse GPS log: ~every 1.5 mi or significant turns', + time: DateTime.now(), + ); + + // Add one track with one segment + final track = Trk(name: 'Main Track'); + _currentSegment = Trkseg(); + track.trksegs.add(_currentSegment); + _gpx.trks.add(track); + + _isInitialized = true; + + // Start location stream + _positionStream = Geolocator.getPositionStream( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + distanceFilter: 152, // meters (~0.16 mi) - helps battery + ), + ).listen(_onPositionReceived); + + // Also poll via timer as fallback + _timer = Timer.periodic(Duration(seconds: (minTime / 2).toInt()), ( + _, + ) async { + final position = await Geolocator.getCurrentPosition(); + await _onPositionReceived(position); + }); + + _lastLoggedPosition = null; + _lastHeading = null; + _lastLoggedTime = null; + + print('Sparse GPX logging started → ${_gpxFile?.path}'); + } + + Future stopLogging() async { + await _positionStream?.cancel(); + _positionStream = null; + _timer?.cancel(); + _timer = null; + + if (_isInitialized && _currentSegment.trkpts.isNotEmpty) { + // Write GPX file on stop + final xmlString = GpxWriter().asString(_gpx, pretty: true); + + await _gpxFile?.writeAsString(xmlString); + + final result = await SharePlus.instance.share( + ShareParams( + text: 'Sparse GPS track', + subject: 'Sparse GPS track', + files: [XFile(_gpxFile?.path ?? '')], + ), + ); + + await _gpxFile?.delete(); + } + + print('Logging stopped'); + } + + Future _onPositionReceived(Position position) async { + final now = DateTime.now(); + final speedKmh = position.speed * 3.6; + final heading = position.heading; + + bool shouldLog = false; + String reason = ''; + + if (_lastLoggedPosition == null) { + shouldLog = true; + reason = 'start'; + } else { + final distanceMeters = Geolocator.distanceBetween( + _lastLoggedPosition!.latitude, + _lastLoggedPosition!.longitude, + position.latitude, + position.longitude, + ); + + if (distanceMeters >= distanceThresholdMeters) { + shouldLog = true; + reason = + 'distance (${(distanceMeters / 1609.34).toStringAsFixed(2)} mi)'; + } else if (speedKmh > minSpeedForTurnKmh && _lastHeading != null) { + double delta = (heading - _lastHeading!).abs(); + delta = math.min(delta, 360 - delta); + if (delta > headingChangeThresholdDeg) { + shouldLog = true; + reason = 'turn (${delta.toStringAsFixed(1)}°)'; + } + } else if (_lastLoggedTime != null) { + final elapsed = now.difference(_lastLoggedTime!).inSeconds; + if (elapsed >= minTime && distanceMeters >= distanceThresholdMeters) { + shouldLog = true; + reason = 'time (${elapsed}s)'; + } + } + } + + if (shouldLog) { + // Create GPX Waypoint (trkpt) + final pt = Wpt( + lat: position.latitude, + lon: position.longitude, + ele: position.altitude, // if available + time: now, + extensions: { + "course": ?heading.isFinite ? heading : null, + "speed": ?speedKmh > 0 ? speedKmh / 3.6 : null, // GPX speed in m/s + }, + // You can add hdop, vdop, etc. from position if desired + ); + + _currentSegment.trkpts.add(pt); + _onNewLogPoint?.call(position); + print('Logged point: ${pt.lat}, ${pt.lon} ($reason)'); + + _lastLoggedPosition = position; + _lastHeading = heading; + _lastLoggedTime = now; + } else { + print('Skipped point: ${position.latitude}, ${position.longitude}'); + } + } + + Future getGpxFilePath() async => _gpxFile?.path ?? 'Not started'; + bool isLogging() => _positionStream != null; + int getPointCount() => _currentSegment.trkpts.length; +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index d2ea57e..dc2aa6b 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,6 +7,7 @@ import Foundation import flutter_blue_plus_darwin import flutter_local_notifications +import geolocator_apple import mobile_scanner import package_info_plus import share_plus @@ -18,6 +19,7 @@ import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 207ff51..dd473f6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -69,10 +69,10 @@ packages: dependency: "direct main" description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -341,6 +341,70 @@ packages: description: flutter source: sdk version: "0.0.0" + geoclue: + dependency: transitive + description: + name: geoclue + sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f + url: "https://pub.dev" + source: hosted + version: "0.1.1" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: "79939537046c9025be47ec645f35c8090ecadb6fe98eba146a0d25e8c1357516" + url: "https://pub.dev" + source: hosted + version: "14.0.2" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: "179c3cb66dfa674fc9ccbf2be872a02658724d1c067634e2c427cf6df7df901a" + url: "https://pub.dev" + source: hosted + version: "5.0.2" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 + url: "https://pub.dev" + source: hosted + version: "2.3.13" + geolocator_linux: + dependency: transitive + description: + name: geolocator_linux + sha256: c4e966f0a7a87e70049eac7a2617f9e16fd4c585a26e4330bdfc3a71e6a721f3 + url: "https://pub.dev" + source: hosted + version: "0.2.3" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67" + url: "https://pub.dev" + source: hosted + version: "4.2.6" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 + url: "https://pub.dev" + source: hosted + version: "4.1.3" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" glob: dependency: transitive description: @@ -357,6 +421,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + gsettings: + dependency: transitive + description: + name: gsettings + sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" + url: "https://pub.dev" + source: hosted + version: "0.2.8" hooks: dependency: transitive description: @@ -489,26 +561,26 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" mgrs_dart: dependency: transitive description: @@ -637,6 +709,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 + url: "https://pub.dev" + source: hosted + version: "12.0.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" petitparser: dependency: transitive description: @@ -910,10 +1030,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.7" timezone: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e1ae023..a168f9e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -60,6 +60,8 @@ dependencies: gpx: ^2.3.0 path_provider: ^2.1.5 share_plus: ^12.0.1 + geolocator: ^14.0.2 + permission_handler: ^12.0.1 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index cd4fc19..eeaa5c2 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,12 +7,18 @@ #include "generated_plugin_registrant.h" #include +#include +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { FlutterBluePlusPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterBluePlusPlugin")); + GeolocatorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GeolocatorWindows")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 571addb..9925ef0 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,8 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_blue_plus_winrt + geolocator_windows + permission_handler_windows share_plus url_launcher_windows )