Implement sparse location logging feature and update related services

This commit is contained in:
Winston Lowe 2026-02-08 17:01:28 -08:00
parent fac062a100
commit cedbe1dd6c
11 changed files with 405 additions and 13 deletions

View file

@ -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;

View file

@ -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();
}

View file

@ -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

View file

@ -357,8 +357,8 @@ class _MapScreenState extends State<MapScreen> {
connector.selfLatitude!,
connector.selfLongitude!,
),
width: 35,
height: 35,
width: 40,
height: 40,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(

View file

@ -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<SettingsScreen> {
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<SettingsScreen> {
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(

View file

@ -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<Position>? _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<void> 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<void> 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<void> _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<String> getGpxFilePath() async => _gpxFile?.path ?? 'Not started';
bool isLogging() => _positionStream != null;
int getPointCount() => _currentSegment.trkpts.length;
}

View file

@ -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"))

View file

@ -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:

View file

@ -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:

View file

@ -7,12 +7,18 @@
#include "generated_plugin_registrant.h"
#include <flutter_blue_plus_winrt/flutter_blue_plus_plugin.h>
#include <geolocator_windows/geolocator_windows.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <share_plus/share_plus_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FlutterBluePlusPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterBluePlusPlugin"));
GeolocatorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("GeolocatorWindows"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
SharePlusWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(

View file

@ -4,6 +4,8 @@
list(APPEND FLUTTER_PLUGIN_LIST
flutter_blue_plus_winrt
geolocator_windows
permission_handler_windows
share_plus
url_launcher_windows
)