diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index 53f56db..97dbe4e 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -1,3 +1,4 @@ +import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:latlong2/latlong.dart'; import '../models/contact.dart'; @@ -55,6 +56,7 @@ class _RetainedRoute { /// Contacts Provider - manages contact list and telemetry class ContactsProvider with ChangeNotifier { + static const double _firstHopFallbackOffsetMeters = 100.0; final Map _contacts = {}; final Map _pendingAdverts = {}; final ContactStorageService _storageService = ContactStorageService(); @@ -367,13 +369,36 @@ class ContactsProvider with ChangeNotifier { incomingContact: incomingContact, existingContact: existingContact, ); + final mergedTelemetry = _mergeTelemetryForContact( + existingTelemetry: existingContact?.telemetry, + incomingTelemetry: incomingContact.telemetry, + ); + final inferredFallbackLocation = _inferFirstHopFallbackLocation( + incomingContact: incomingContact, + existingContact: existingContact, + retainedRoute: retainedRoute, + mergedTelemetry: mergedTelemetry, + ); + final inferredFallbackAdvLat = inferredFallbackLocation != null + ? _coordinateToAdvertMicrodegrees(inferredFallbackLocation.latitude) + : null; + final inferredFallbackAdvLon = inferredFallbackLocation != null + ? _coordinateToAdvertMicrodegrees(inferredFallbackLocation.longitude) + : null; if (existingContact == null) { var newContact = incomingContact.copyWith( isNew: true, + telemetry: mergedTelemetry, outPathLen: retainedRoute?.signedEncodedPathLen ?? incomingContact.outPathLen, outPath: retainedRoute?.paddedPathBytes ?? incomingContact.outPath, + advLat: incomingContact.advertLocation != null + ? incomingContact.advLat + : inferredFallbackAdvLat ?? incomingContact.advLat, + advLon: incomingContact.advertLocation != null + ? incomingContact.advLon + : inferredFallbackAdvLon ?? incomingContact.advLon, ); if (incomingContact.advertLocation != null) { final timestamp = DateTime.fromMillisecondsSinceEpoch( @@ -387,10 +412,6 @@ class ContactsProvider with ChangeNotifier { return newContact; } - final mergedTelemetry = _mergeTelemetryForContact( - existingTelemetry: existingContact.telemetry, - incomingTelemetry: incomingContact.telemetry, - ); final incomingAdvertLocation = incomingContact.advertLocation; final existingAdvertLocation = existingContact.advertLocation; @@ -405,12 +426,12 @@ class ContactsProvider with ChangeNotifier { ? incomingContact.advLat : existingAdvertLocation != null ? existingContact.advLat - : incomingContact.advLat, + : inferredFallbackAdvLat ?? incomingContact.advLat, advLon: incomingAdvertLocation != null ? incomingContact.advLon : existingAdvertLocation != null ? existingContact.advLon - : incomingContact.advLon, + : inferredFallbackAdvLon ?? incomingContact.advLon, ); if (incomingAdvertLocation != null) { @@ -457,6 +478,114 @@ class ContactsProvider with ChangeNotifier { return null; } + LatLng? _inferFirstHopFallbackLocation({ + required Contact incomingContact, + required Contact? existingContact, + required _RetainedRoute? retainedRoute, + required ContactTelemetry? mergedTelemetry, + }) { + if (_getValidGpsOrNull(mergedTelemetry?.gpsLocation) != null) { + return null; + } + if (incomingContact.advertLocation != null || + existingContact?.advertLocation != null) { + return null; + } + + final routeBytes = + retainedRoute?.paddedPathBytes ?? + (incomingContact.routeHasPath + ? incomingContact.routePathBytes + : existingContact?.routeHasPath == true + ? existingContact!.routePathBytes + : null); + final routeHashSize = retainedRoute != null + ? ((ContactRouteCodec.toUnsignedDescriptor( + retainedRoute.signedEncodedPathLen, + ) >> + 6) + + 1) + : incomingContact.routeHasPath + ? incomingContact.routeHashSize + : existingContact?.routeHasPath == true + ? existingContact!.routeHashSize + : 0; + if (routeBytes == null || + routeHashSize <= 0 || + routeBytes.length < routeHashSize) { + return null; + } + + final firstHopHex = routeBytes + .sublist(0, routeHashSize) + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + final repeaterCandidates = _contacts.values.where((candidate) { + if (!candidate.isRepeater || + candidate.publicKeyHex == incomingContact.publicKeyHex) { + return false; + } + final location = candidate.displayLocation; + return location != null && candidate.publicKeyHex.startsWith(firstHopHex); + }).toList()..sort((a, b) => b.lastAdvert.compareTo(a.lastAdvert)); + if (repeaterCandidates.isEmpty) { + return null; + } + + final repeaterLocation = repeaterCandidates.first.displayLocation; + if (repeaterLocation == null) { + return null; + } + + final bearingDegrees = _stableFallbackBearingDegrees(incomingContact); + return _offsetFromLocation( + repeaterLocation, + distanceMeters: _firstHopFallbackOffsetMeters, + bearingDegrees: bearingDegrees, + ); + } + + double _stableFallbackBearingDegrees(Contact contact) { + if (contact.publicKey.length < 2) { + return 90.0; + } + final seed = (contact.publicKey[0] << 8) | contact.publicKey[1]; + return (seed % 360).toDouble(); + } + + LatLng _offsetFromLocation( + LatLng origin, { + required double distanceMeters, + required double bearingDegrees, + }) { + const earthRadiusMeters = 6371000.0; + final angularDistance = distanceMeters / earthRadiusMeters; + final bearingRadians = bearingDegrees * 3.1415926535897932 / 180.0; + final lat1 = origin.latitude * 3.1415926535897932 / 180.0; + final lon1 = origin.longitude * 3.1415926535897932 / 180.0; + + final sinLat1 = sin(lat1); + final cosLat1 = cos(lat1); + final sinAngularDistance = sin(angularDistance); + final cosAngularDistance = cos(angularDistance); + + final lat2 = asin( + sinLat1 * cosAngularDistance + + cosLat1 * sinAngularDistance * cos(bearingRadians), + ); + final lon2 = + lon1 + + atan2( + sin(bearingRadians) * sinAngularDistance * cosLat1, + cosAngularDistance - sinLat1 * sin(lat2), + ); + + return LatLng( + lat2 * 180.0 / 3.1415926535897932, + ((lon2 * 180.0 / 3.1415926535897932 + 540.0) % 360.0) - 180.0, + ); + } + /// Update contact telemetry void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) { debugPrint('📊 [ContactsProvider] updateTelemetry() called'); diff --git a/lib/services/message_storage_service.dart b/lib/services/message_storage_service.dart index 4e5136f..265c481 100644 --- a/lib/services/message_storage_service.dart +++ b/lib/services/message_storage_service.dart @@ -19,6 +19,8 @@ class MessageStorageService { 'stored_message_transfer_details'; static const String _messageRouteMetadataKey = 'stored_message_route_metadata'; + static const String _embeddedReceptionDetailsKey = 'storedReceptionDetails'; + static const String _legacyPathBytesKey = 'storedPathBytes'; static const int _maxStoredMessages = 1000; // Store up to 1000 messages /// Save messages to persistent storage @@ -32,8 +34,16 @@ class MessageStorageService { try { final prefs = await SharedPreferences.getInstance(); - // Convert messages to JSON - final jsonList = messages.map((msg) => _messageToJson(msg)).toList(); + // Convert messages to JSON and embed path bytes as a fallback so they + // survive restore even if the sidecar reception-details entry is absent. + final jsonList = messages + .map( + (msg) => _messageToJson( + msg, + receptionDetails: messageReceptionDetails[msg.id], + ), + ) + .toList(); // Limit to max stored messages (keep most recent) final limitedList = jsonList.length > _maxStoredMessages @@ -129,24 +139,36 @@ class MessageStorageService { try { final prefs = await SharedPreferences.getInstance(); final jsonString = prefs.getString(_messageReceptionDetailsKey); - if (jsonString == null || jsonString.isEmpty) { - return const {}; - } - - final decoded = jsonDecode(jsonString); - if (decoded is! Map) { - return const {}; - } - final result = {}; - for (final entry in decoded.entries) { - final value = entry.value; - if (value is! Map) continue; - final snapshot = MessageReceptionDetails.fromJson(value); - if (snapshot != null) { - result[entry.key] = snapshot; + if (jsonString != null && jsonString.isNotEmpty) { + final decoded = jsonDecode(jsonString); + if (decoded is Map) { + for (final entry in decoded.entries) { + final value = entry.value; + if (value is! Map) continue; + final snapshot = MessageReceptionDetails.fromJson(value); + if (snapshot != null) { + result[entry.key] = snapshot; + } + } } } + + final embeddedReceptionDetails = await _loadEmbeddedReceptionDetails(); + embeddedReceptionDetails.forEach((messageId, snapshot) { + result.putIfAbsent(messageId, () => snapshot); + }); + + final fallbackPathBytes = await _loadLegacyPathBytesFromMessages(); + fallbackPathBytes.forEach((messageId, pathBytes) { + result.putIfAbsent( + messageId, + () => MessageReceptionDetails( + capturedAt: DateTime.fromMillisecondsSinceEpoch(0), + pathBytes: pathBytes, + ), + ); + }); return result; } catch (e) { debugPrint('❌ [MessageStorage] Error loading reception details: $e'); @@ -278,7 +300,10 @@ class MessageStorageService { } /// Convert Message to JSON - Map _messageToJson(Message message) { + Map _messageToJson( + Message message, { + MessageReceptionDetails? receptionDetails, + }) { return { 'id': message.id, 'messageType': message.messageType.name, @@ -338,9 +363,67 @@ class MessageStorageService { }, ) .toList(), + if (receptionDetails != null) + _embeddedReceptionDetailsKey: receptionDetails.toJson(), + if (receptionDetails?.pathBytes case final pathBytes?) + _legacyPathBytesKey: List.from(pathBytes), }; } + Future> + _loadEmbeddedReceptionDetails() async { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString(_messagesKey); + if (jsonString == null || jsonString.isEmpty) { + return const {}; + } + + final decoded = jsonDecode(jsonString); + if (decoded is! List) { + return const {}; + } + + final result = {}; + for (final entry in decoded) { + if (entry is! Map) continue; + final messageId = entry['id']; + final embedded = entry[_embeddedReceptionDetailsKey]; + if (messageId is! String || embedded is! Map) continue; + final snapshot = MessageReceptionDetails.fromJson(embedded); + if (snapshot == null) continue; + result[messageId] = snapshot; + } + return result; + } + + Future>> _loadLegacyPathBytesFromMessages() async { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString(_messagesKey); + if (jsonString == null || jsonString.isEmpty) { + return const {}; + } + + final decoded = jsonDecode(jsonString); + if (decoded is! List) { + return const {}; + } + + final result = >{}; + for (final entry in decoded) { + if (entry is! Map) continue; + final messageId = entry['id']; + final pathBytes = entry[_legacyPathBytesKey]; + if (messageId is! String || pathBytes is! List) continue; + final normalized = pathBytes + .whereType() + .map((b) => b.toInt()) + .toList(); + if (normalized.isEmpty) continue; + result[messageId] = normalized; + } + return result; + } + /// Convert JSON to Message Message? _messageFromJson(Map json) { try { diff --git a/test/providers/contacts_provider_test.dart b/test/providers/contacts_provider_test.dart index b62e814..ba57646 100644 --- a/test/providers/contacts_provider_test.dart +++ b/test/providers/contacts_provider_test.dart @@ -1,5 +1,6 @@ import 'dart:typed_data'; +import 'package:geolocator/geolocator.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:latlong2/latlong.dart'; import 'package:meshcore_sar_app/models/contact.dart'; @@ -522,6 +523,58 @@ void main() { isEmpty, ); }); + + test('infers a fallback location 100m from first-hop repeater', () { + final repeaterKey = Uint8List.fromList([ + 0xAA, + 0xBB, + 0x10, + 0x11, + 0x12, + 0x13, + ...List.generate(26, (index) => index + 20), + ]); + provider.addOrUpdateContact( + createContact( + key: repeaterKey, + type: ContactType.repeater, + name: 'Relay Alpha', + ), + ); + + final targetKey = createPublicKey(120); + final route = ContactRouteCodec.parse('AABB,CCDD'); + provider.addOrUpdateContact( + Contact( + publicKey: targetKey, + type: ContactType.chat, + flags: 0, + outPathLen: route.signedEncodedPathLen, + outPath: route.paddedPathBytes, + advName: 'No GPS', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), + ); + + final updated = provider.findContactByKey(targetKey)!; + final inferred = updated.displayLocation; + final repeater = provider.findContactByKey(repeaterKey)!; + final repeaterLocation = repeater.displayLocation; + + expect(inferred, isNotNull); + expect(repeaterLocation, isNotNull); + + final distanceMeters = Geolocator.distanceBetween( + repeaterLocation!.latitude, + repeaterLocation.longitude, + inferred!.latitude, + inferred.longitude, + ); + expect(distanceMeters, closeTo(100.0, 8.0)); + }); }); group('ContactsProvider.updateFastGps', () { diff --git a/test/services/message_storage_service_test.dart b/test/services/message_storage_service_test.dart new file mode 100644 index 0000000..dc28f90 --- /dev/null +++ b/test/services/message_storage_service_test.dart @@ -0,0 +1,101 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_client/meshcore_client.dart'; +import 'package:meshcore_sar_app/models/message_reception_details.dart'; +import 'package:meshcore_sar_app/services/message_storage_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test( + 'retains path bytes for stored unread message when reception sidecar is missing', + () async { + final storage = MessageStorageService(); + final message = Message( + id: 'msg-1', + messageType: MessageType.contact, + senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]), + pathLen: 2, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: 'Unread message', + receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500), + isRead: false, + ); + + await storage.saveMessages( + [message], + messageReceptionDetails: { + message.id: MessageReceptionDetails( + capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000000600), + pathBytes: const [0xAA, 0xBB, 0xCC], + ), + }, + ); + + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('stored_message_reception_details'); + + final restoredDetails = await storage.loadMessageReceptionDetails(); + + expect(restoredDetails.keys, contains(message.id)); + expect(restoredDetails[message.id]?.pathBytes, [0xAA, 0xBB, 0xCC]); + }, + ); + + test('retains embedded reception details when sidecar is missing', () async { + final storage = MessageStorageService(); + final message = Message( + id: 'msg-2', + messageType: MessageType.channel, + senderPublicKeyPrefix: Uint8List.fromList([6, 5, 4, 3, 2, 1]), + channelIdx: 2, + pathLen: 3, + textType: MessageTextType.plain, + senderTimestamp: 1700000100, + text: 'Room update', + receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000100500), + isRead: false, + ); + + await storage.saveMessages( + [message], + messageReceptionDetails: { + message.id: MessageReceptionDetails( + capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000100600), + packetLoggedAt: DateTime.fromMillisecondsSinceEpoch(1700000100400), + rssiDbm: -91, + snrDb: 7.25, + pathBytes: const [0xAA, 0xBB, 0xCC, 0xDD], + senderToReceiptMs: 1200, + estimatedTransmitMs: 800, + postTransmitDelayMs: 400, + ), + }, + ); + + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('stored_message_reception_details'); + + final restoredDetails = await storage.loadMessageReceptionDetails(); + final restored = restoredDetails[message.id]; + + expect(restored, isNotNull); + expect(restored!.pathBytes, [0xAA, 0xBB, 0xCC, 0xDD]); + expect(restored.rssiDbm, -91); + expect(restored.snrDb, 7.25); + expect(restored.senderToReceiptMs, 1200); + expect(restored.estimatedTransmitMs, 800); + expect(restored.postTransmitDelayMs, 400); + expect( + restored.packetLoggedAt, + DateTime.fromMillisecondsSinceEpoch(1700000100400), + ); + }); +}