diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index c4ec879..34ceefa 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:crypto/crypto.dart' as crypto; -import 'package:geolocator_platform_interface/src/models/position.dart'; +import 'package:geolocator/geolocator.dart'; import 'package:meshcore_open/services/sparse_location_logger.dart'; import 'package:pointycastle/export.dart'; import 'package:flutter/foundation.dart'; @@ -1699,6 +1699,11 @@ class MeshCoreConnector extends ChangeNotifier { _isLoadingContacts = true; notifyListeners(); break; + case pushCodeNewAdvert: + debugPrint('Got New CONTACT'); + // It the same format as respCodeContact, so we can reuse the handler + _handleContact(frame); + break; case respCodeContact: debugPrint('Got CONTACT'); _handleContact(frame); @@ -1743,6 +1748,7 @@ class MeshCoreConnector extends ChangeNotifier { case pushCodeStatusResponse: break; case pushCodeLogRxData: + _handleRxData(frame); _handleLogRxData(frame); break; case respCodeChannelInfo: @@ -1756,6 +1762,7 @@ class MeshCoreConnector extends ChangeNotifier { break; case respCodeCustomVars: _handleCustomVars(frame); + break; default: debugPrint('Unknown frame code: $code'); } @@ -2011,6 +2018,76 @@ class MeshCoreConnector extends ChangeNotifier { } } + void _handleContactAdvert(Contact contact) { + if (contact.type == advTypeRepeater) { + _contactUnreadCount.remove(contact.publicKeyHex); + _unreadStore.saveContactUnreadCount( + Map.from(_contactUnreadCount), + ); + } + // Check if this is a new contact + final isNewContact = !_knownContactKeys.contains(contact.publicKeyHex); + final existingIndex = _contacts.indexWhere( + (c) => c.publicKeyHex == contact.publicKeyHex, + ); + + if (existingIndex >= 0) { + final existing = _contacts[existingIndex]; + final mergedLastMessageAt = + existing.lastMessageAt.isAfter(contact.lastMessageAt) + ? existing.lastMessageAt + : contact.lastMessageAt; + + appLogger.info( + 'Refreshing contact ${contact.name}: devicePath=${contact.pathLength}, existingOverride=${existing.pathOverride}', + tag: 'Connector', + ); + + // CRITICAL: Preserve user's path override when contact is refreshed from device + _contacts[existingIndex] = contact.copyWith( + lastMessageAt: mergedLastMessageAt, + pathOverride: existing.pathOverride, // Preserve user's path choice + pathOverrideBytes: existing.pathOverrideBytes, + ); + + appLogger.info( + 'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}', + tag: 'Connector', + ); + } else { + _contacts.add(contact); + appLogger.info( + 'Added new contact ${contact.name}: pathLen=${contact.pathLength}', + tag: 'Connector', + ); + } + _knownContactKeys.add(contact.publicKeyHex); + _loadMessagesForContact(contact.publicKeyHex); + + // Add path to history if we have a valid path + if (_pathHistoryService != null && contact.pathLength >= 0) { + _pathHistoryService!.handlePathUpdated(contact); + } + + notifyListeners(); + + // Show notification for new contact (advertisement) + if (isNewContact && _appSettingsService != null) { + final settings = _appSettingsService!.settings; + if (settings.notificationsEnabled && settings.notifyOnNewAdvert) { + _notificationService.showAdvertNotification( + contactName: contact.name, + contactType: contact.typeLabel, + contactId: contact.publicKeyHex, + ); + } + } + + if (!_isLoadingContacts) { + unawaited(_persistContacts()); + } + } + Future _persistContacts() async { await _contactStore.saveContacts(_contacts); } @@ -3296,20 +3373,133 @@ class MeshCoreConnector extends ChangeNotifier { } _updateLocationandAdvert(Position position) async { - double lat = position.latitude; - double lon = position.longitude; + final snapToGridCenter = _sparseLocationLogger?.snapToGridCenter( + position: position, + cellSizeMeters: 0.001, + ); + double lat = snapToGridCenter?.latitude ?? 0.0; + double lon = snapToGridCenter?.longitude ?? 0.0; if (lat == 0.0 && lon == 0.0) { - // Invalid location + debugPrint('Invalid location (0,0), skipping advert'); 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 sendFrame(buildSetOtherParamsFrame(true, 1, 1, 0)); await setNodeLocation(lat: lat, lon: lon); await sendSelfAdvert(flood: true); - await sendFrame(buildDeviceQueryFrame()); + _selfLatitude = lat; + _selfLongitude = lon; + notifyListeners(); + } + + void _handleRxData(Uint8List frame) { + final packet = BufferReader(frame); + packet.skipBytes(3); // Skip frame type byte + //final snr = packet.readByte() / 4.0; + //final rssi = packet.readByte(); + final header = packet.readByte(); + //final routeType = header & 0x03; + final payloadType = (header >> 2) & 0x0F; + //final payloadVer = (header >> 6) & 0x03; + + if (packet.remaining <= 0) return; + final pathLen = packet.readByte(); + + if (packet.remaining < pathLen) return; + final pathBytes = packet.readBytes(pathLen); + + if (packet.remaining <= 0) return; + final payload = packet.readBytes(packet.remaining); + + switch (payloadType) { + case payloadTypeADVERT: + _handlePayloadAdvertReceived(payload, pathBytes); + break; + default: + } + } + + void _handlePayloadAdvertReceived(Uint8List frame, Uint8List path) { + final advert = BufferReader(frame); + if (advert.remaining <= 32) return; + final publicKey = advert.readBytes(32); + final contactKeyHex = publicKey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + if (advert.remaining <= 4) return; + final timestamp = advert.readInt32LE(); + if (advert.remaining <= 64) return; + advert.skipBytes(64); // Skip signature for now + if (advert.remaining <= 1) return; + final flags = advert.readByte(); + final type = flags & 0x0F; + final hasLocation = (flags & 0x10) != 0; + //final hasFeature1 = (flags & 0x20) != 0; + //final hasFeature2 = (flags & 0x40) != 0; + final hasName = (flags & 0x80) != 0; + double latitude = 0.0; + double longitude = 0.0; + if (hasLocation && advert.remaining >= 8) { + latitude = advert.readInt32LE() / 1e6; + longitude = advert.readInt32LE() / 1e6; + } + String name = ''; + if (hasName && advert.remaining > 0) { + name = advert.readString(); + } + // Check if this is a new contact + final isNewContact = !_knownContactKeys.contains(contactKeyHex); + if (isNewContact) { + _handleContactAdvert( + Contact( + publicKey: publicKey, + name: name, + type: type, + pathLength: path.length, + path: path, + latitude: latitude, + longitude: longitude, + lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), + ), + ); + return; + } + + final existingIndex = _contacts.indexWhere( + (c) => c.publicKeyHex == contactKeyHex, + ); + + if (existingIndex >= 0) { + final existing = _contacts[existingIndex]; + final mergedLastMessageAt = existing.lastMessageAt.isAfter(DateTime.now()) + ? DateTime.now() + : existing.lastMessageAt; + + appLogger.info( + 'Refreshing contact ${existing.name}: devicePath=${existing.pathLength}, existingOverride=${existing.pathOverride}', + tag: 'Connector', + ); + + // CRITICAL: Preserve user's path override when contact is refreshed from device + _contacts[existingIndex] = existing.copyWith( + latitude: hasLocation ? latitude : existing.latitude, + longitude: hasLocation ? longitude : existing.longitude, + name: hasName ? name : existing.name, + path: path, + pathLength: path.length, + lastMessageAt: mergedLastMessageAt, + lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), + pathOverride: existing.pathOverride, // Preserve user's path choice + pathOverrideBytes: existing.pathOverrideBytes, + ); + + appLogger.info( + 'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}', + tag: 'Connector', + ); + notifyListeners(); + } } } diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index 12e39fe..861ded6 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -213,6 +213,30 @@ const int advTypeRepeater = 2; const int advTypeRoom = 3; const int advTypeSensor = 4; +// Payload Types +const int payloadTypeREQ = + 0x00; // request (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob) +const int payloadTypeRESPONSE = + 0x01; // response to REQ or ANON_REQ (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob) +const int payloadTypeTXTMSG = + 0x02; // a plain text message (prefixed with dest/src hashes, MAC) (enc data: timestamp, text) +const int payloadTypeACK = 0x03; // a simple ack +const int payloadTypeADVERT = 0x04; // a node advertising its Identity +const int payloadTypeGRPTXT = + 0x05; // an (unverified) group text message (prefixed with channel hash, MAC) (enc data: timestamp, "name: msg") +const int payloadTypeGRPDATA = + 0x06; // an (unverified) group datagram (prefixed with channel hash, MAC) (enc data: timestamp, blob) +const int payloadTypeANONREQ = + 0x07; // generic request (prefixed with dest_hash, ephemeral pub_key, MAC) (enc data: ...) +const int payloadTypePATH = + 0x08; // returned path (prefixed with dest/src hashes, MAC) (enc data: path, extra) +const int payloadTypeTRACE = 0x09; // trace a path, collecting SNI for each hop +const int payloadTypeMULTIPART = 0x0A; // packet is one of a set of packets +const int payloadTypeCONTROL = 0x0B; // a control/discovery packet +//... +const int payloadTypeRawCustom = + 0x0F; // custom packet as raw bytes, for applications with custom encryption, payloads, etc + // Sizes const int pubKeySize = 32; const int maxPathSize = 64; @@ -780,20 +804,20 @@ Uint8List buildZeroHopContact(Uint8List pubKey) { } // Build CMD_SET_OTHER_PARAMS frame -// Format: [cmd][allowAutoAddContacts][allowTelemetryFlags][advert_loc_policy][multi_acks] +// Format: [cmd][allowAutoAddContacts][allowTelemetryFlags][advertLocationPolicy][multiAcks] Uint8List buildSetOtherParamsFrame( bool allowAutoAddContacts, int allowTelemetryFlags, - int advert_loc_policy, - int multi_acks, + int advertLocationPolicy, + int multiAcks, ) { final writer = BufferWriter(); writer.writeByte(cmdSetOtherParams); writer.writeByte( - allowAutoAddContacts ? 0x01 : 0x00, + allowAutoAddContacts ? 0x00 : 0x01, ); // Allow Auto Add Contacts writer.writeByte(allowTelemetryFlags); // Allow Telemetry Flags - writer.writeByte(advert_loc_policy); // Advertisement Location Policy - writer.writeByte(multi_acks); // Multi Acknowledgements + writer.writeByte(advertLocationPolicy); // Advertisement Location Policy + writer.writeByte(multiAcks); // Multi Acknowledgements return writer.toBytes(); } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0c54be3..52a4340 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -619,6 +619,7 @@ "map_sharedPin": "Shared pin", "map_joinRoom": "Join Room", "map_manageRepeater": "Manage Repeater", + "map_updateMyLocation": "Update Location", "mapCache_title": "Offline Map Cache", "mapCache_selectAreaFirst": "Select an area to cache first", "mapCache_noTilesToDownload": "No tiles to download for this area", diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index ad20b59..0da327d 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -275,6 +275,17 @@ class _MapScreenState extends State { ), ), ), + PopupMenuItem( + child: Row( + children: [ + const Icon(Icons.refresh), + const SizedBox(width: 8), + Text(context.l10n.map_updateMyLocation), + ], + ), + onTap: () => + connector.sparseLocationLogger?.updateMyLocation(), + ), ], icon: const Icon(Icons.more_vert), ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index d286a39..4e2dce3 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -3,7 +3,6 @@ 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'; diff --git a/lib/services/sparse_location_logger.dart b/lib/services/sparse_location_logger.dart index 5b8ae62..8a25bfb 100644 --- a/lib/services/sparse_location_logger.dart +++ b/lib/services/sparse_location_logger.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'dart:math' as math; +import 'package:flutter/widgets.dart'; import 'package:geolocator/geolocator.dart'; import 'package:gpx/gpx.dart'; import 'package:path_provider/path_provider.dart'; @@ -34,17 +35,24 @@ class SparseLocationLogger { _onNewLogPoint = onNewLogPoint; } - Future startLogging() async { + Future getPremissons() async { // Permissions & service check (same as before) var status = await Permission.location.request(); if (!status.isGranted) { - print('Location permission denied'); - return; + debugPrint('Location permission denied'); + return false; } bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) { - print('Location services disabled'); + debugPrint('Location services disabled'); + return false; + } + return true; + } + + Future startLogging() async { + if (!await getPremissons()) { return; } @@ -92,7 +100,7 @@ class SparseLocationLogger { _lastHeading = null; _lastLoggedTime = null; - print('Sparse GPX logging started → ${_gpxFile?.path}'); + debugPrint('Sparse GPX logging started → ${_gpxFile?.path}'); } Future stopLogging() async { @@ -107,7 +115,7 @@ class SparseLocationLogger { await _gpxFile?.writeAsString(xmlString); - final result = await SharePlus.instance.share( + await SharePlus.instance.share( ShareParams( text: 'Sparse GPS track', subject: 'Sparse GPS track', @@ -118,7 +126,20 @@ class SparseLocationLogger { await _gpxFile?.delete(); } - print('Logging stopped'); + debugPrint('Logging stopped'); + } + + Future updateMyLocation() async { + if (!await getPremissons()) { + return; + } + + try { + final position = await Geolocator.getCurrentPosition(); + _onNewLogPoint?.call(position); + } catch (e) { + debugPrint('Error updating location: $e'); + } } Future _onPositionReceived(Position position) async { @@ -176,30 +197,30 @@ class SparseLocationLogger { _currentSegment.trkpts.add(pt); _onNewLogPoint?.call(position); - print('Logged point: ${pt.lat}, ${pt.lon} ($reason)'); + debugPrint('Logged point: ${pt.lat}, ${pt.lon} ($reason)'); _lastLoggedPosition = position; _lastHeading = heading; _lastLoggedTime = now; } else { - print('Skipped point: ${position.latitude}, ${position.longitude}'); + debugPrint('Skipped point: ${position.latitude}, ${position.longitude}'); } } Position snapToGridCenter({ required Position position, - required double cellSizeDegrees, // e.g. 0.01 ≈ 1.1 km, 0.001 ≈ 110 m + required double cellSizeMeters, }) { Position snappedPosition = position; // Snap latitude final latFloor = - (position.latitude / cellSizeDegrees).floor() * cellSizeDegrees; - final snappedLat = latFloor + (cellSizeDegrees / 2); + (position.latitude / cellSizeMeters).floor() * cellSizeMeters; + final snappedLat = latFloor + (cellSizeMeters / 2); // Snap longitude final lonFloor = - (position.longitude / cellSizeDegrees).floor() * cellSizeDegrees; - final snappedLon = lonFloor + (cellSizeDegrees / 2); + (position.longitude / cellSizeMeters).floor() * cellSizeMeters; + final snappedLon = lonFloor + (cellSizeMeters / 2); snappedPosition = Position( latitude: snappedLat,