Log contact data without revealing

This commit is contained in:
Janez T
2026-03-06 20:47:02 +01:00
parent ca8e8e6ecb
commit 0e1a56d765
12 changed files with 745 additions and 149 deletions

View File

@@ -0,0 +1,63 @@
import 'package:latlong2/latlong.dart';
class MessageContactLocation {
final LatLng location;
final String source;
final DateTime capturedAt;
final DateTime? sourceTimestamp;
const MessageContactLocation({
required this.location,
required this.source,
required this.capturedAt,
this.sourceTimestamp,
});
String get technicalSourceLabel {
switch (source) {
case 'telemetry':
return 'telemetry';
case 'advert':
return 'advert';
default:
return source;
}
}
String get formattedCoordinates =>
'${location.latitude.toStringAsFixed(6)}, ${location.longitude.toStringAsFixed(6)}';
Map<String, dynamic> toJson() {
return {
'latitude': location.latitude,
'longitude': location.longitude,
'source': source,
'capturedAtMillis': capturedAt.millisecondsSinceEpoch,
'sourceTimestampMillis': sourceTimestamp?.millisecondsSinceEpoch,
};
}
static MessageContactLocation? fromJson(Map<String, dynamic> json) {
final latitude = json['latitude'];
final longitude = json['longitude'];
final source = json['source'];
final capturedAtMillis = json['capturedAtMillis'];
if (latitude is! num ||
longitude is! num ||
source is! String ||
capturedAtMillis is! int) {
return null;
}
return MessageContactLocation(
location: LatLng(latitude.toDouble(), longitude.toDouble()),
source: source,
capturedAt: DateTime.fromMillisecondsSinceEpoch(capturedAtMillis),
sourceTimestamp: json['sourceTimestampMillis'] is int
? DateTime.fromMillisecondsSinceEpoch(
json['sourceTimestampMillis'] as int,
)
: null,
);
}
}

View File

@@ -607,14 +607,30 @@ class AppProvider with ChangeNotifier {
connectionProvider.onMessageReceived = (message) { connectionProvider.onMessageReceived = (message) {
// Enrich message with sender name from contacts first // Enrich message with sender name from contacts first
Message enrichedMessage = message; Message enrichedMessage = message;
Contact? senderContact;
if (message.senderPublicKeyPrefix != null && message.senderName == null) { if (message.senderPublicKeyPrefix != null && message.senderName == null) {
final contact = contactsProvider.findContactByKey( final contact = contactsProvider.findContactByKey(
message.senderPublicKeyPrefix!, message.senderPublicKeyPrefix!,
); );
if (contact != null) { if (contact != null) {
senderContact = contact;
enrichedMessage = message.copyWith(senderName: contact.advName); enrichedMessage = message.copyWith(senderName: contact.advName);
} }
} }
senderContact ??= message.senderPublicKeyPrefix != null
? contactsProvider.findContactByKey(message.senderPublicKeyPrefix!)
: null;
senderContact ??= enrichedMessage.senderName != null
? contactsProvider.contacts
.where((c) => c.advName == enrichedMessage.senderName)
.firstOrNull
: null;
final contactLocationSnapshot = senderContact != null
? contactsProvider.buildMessageContactLocationSnapshot(
senderContact,
capturedAt: enrichedMessage.receivedAt,
)
: null;
// Check if message is a drawing broadcast // Check if message is a drawing broadcast
if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) { if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) {
@@ -645,6 +661,7 @@ class AppProvider with ChangeNotifier {
messagesProvider.addMessage( messagesProvider.addMessage(
updatedMessage, updatedMessage,
contactLookup: (name) => '', contactLookup: (name) => '',
contactLocationSnapshot: contactLocationSnapshot,
); );
// Broadcast drawing message to SSE clients if server is running // Broadcast drawing message to SSE clients if server is running
@@ -680,6 +697,7 @@ class AppProvider with ChangeNotifier {
return ''; return '';
} }
}, },
contactLocationSnapshot: contactLocationSnapshot,
); );
connectionProvider.broadcastMessageToSseClients(enrichedMessage); connectionProvider.broadcastMessageToSseClients(enrichedMessage);
return; return;
@@ -707,6 +725,7 @@ class AppProvider with ChangeNotifier {
return ''; return '';
} }
}, },
contactLocationSnapshot: contactLocationSnapshot,
); );
connectionProvider.broadcastMessageToSseClients(enrichedMessage); connectionProvider.broadcastMessageToSseClients(enrichedMessage);
return; return;
@@ -743,6 +762,7 @@ class AppProvider with ChangeNotifier {
return ''; return '';
} }
}, },
contactLocationSnapshot: contactLocationSnapshot,
); );
// Broadcast message to SSE clients if server is running // Broadcast message to SSE clients if server is running

View File

@@ -1,6 +1,7 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/message_contact_location.dart';
import '../services/cayenne_lpp_parser.dart'; import '../services/cayenne_lpp_parser.dart';
import '../services/contact_storage_service.dart'; import '../services/contact_storage_service.dart';
import '../utils/key_comparison.dart'; import '../utils/key_comparison.dart';
@@ -214,6 +215,52 @@ class ContactsProvider with ChangeNotifier {
List<Contact> get chatContactsWithLocation => List<Contact> get chatContactsWithLocation =>
chatContacts.where((c) => c.displayLocation != null).toList(); chatContacts.where((c) => c.displayLocation != null).toList();
MessageContactLocation? buildMessageContactLocationSnapshot(
Contact contact, {
DateTime? capturedAt,
}) {
final snapshotTime = capturedAt ?? DateTime.now();
final telemetryGps = _getValidGpsOrNull(contact.telemetry?.gpsLocation);
final telemetryTimestamp = contact.telemetry?.timestamp;
AdvertLocation? advertLocation;
for (final point in contact.advertHistory) {
if (!point.timestamp.isAfter(snapshotTime)) {
advertLocation = point;
break;
}
}
advertLocation ??= contact.advertHistory.isNotEmpty
? contact.advertHistory.first
: null;
if (telemetryGps != null) {
final shouldUseTelemetry =
telemetryTimestamp == null ||
advertLocation == null ||
!telemetryTimestamp.isBefore(advertLocation.timestamp);
if (shouldUseTelemetry) {
return MessageContactLocation(
location: telemetryGps,
source: 'telemetry',
capturedAt: snapshotTime,
sourceTimestamp: telemetryTimestamp,
);
}
}
if (advertLocation != null) {
return MessageContactLocation(
location: advertLocation.location,
source: 'advert',
capturedAt: snapshotTime,
sourceTimestamp: advertLocation.timestamp,
);
}
return null;
}
/// Sort contacts by last seen (most recent first) /// Sort contacts by last seen (most recent first)
int _sortByLastSeen(Contact a, Contact b) { int _sortByLastSeen(Contact a, Contact b) {
return b.lastSeenTime.compareTo(a.lastSeenTime); return b.lastSeenTime.compareTo(a.lastSeenTime);

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../models/message.dart'; import '../models/message.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/message_contact_location.dart';
import '../models/sar_marker.dart'; import '../models/sar_marker.dart';
import '../models/map_drawing.dart'; import '../models/map_drawing.dart';
import '../services/message_storage_service.dart'; import '../services/message_storage_service.dart';
@@ -20,6 +21,7 @@ class MessagesProvider with ChangeNotifier {
final NotificationService _notificationService = NotificationService(); final NotificationService _notificationService = NotificationService();
bool _isInitialized = false; bool _isInitialized = false;
AppLocalizations? _localizations; AppLocalizations? _localizations;
final Map<String, MessageContactLocation> _messageContactLocations = {};
// Track pending sent messages by expected ACK/TAG // Track pending sent messages by expected ACK/TAG
final Map<int, Message> _pendingSentMessages = {}; final Map<int, Message> _pendingSentMessages = {};
@@ -104,6 +106,9 @@ class MessagesProvider with ChangeNotifier {
String? get targetMessageId => _targetMessageId; String? get targetMessageId => _targetMessageId;
MessageContactLocation? getMessageContactLocation(String messageId) =>
_messageContactLocations[messageId];
/// Set localizations for notifications /// Set localizations for notifications
void setLocalizations(AppLocalizations localizations) { void setLocalizations(AppLocalizations localizations) {
_localizations = localizations; _localizations = localizations;
@@ -132,6 +137,11 @@ class MessagesProvider with ChangeNotifier {
try { try {
debugPrint('📦 [MessagesProvider] Loading persisted messages...'); debugPrint('📦 [MessagesProvider] Loading persisted messages...');
final storedMessages = await _storageService.loadMessages(); final storedMessages = await _storageService.loadMessages();
final storedContactLocations = await _storageService
.loadMessageContactLocations();
_messageContactLocations
..clear()
..addAll(storedContactLocations);
// Add stored messages with enhancement to ensure SAR detection // Add stored messages with enhancement to ensure SAR detection
for (final message in storedMessages) { for (final message in storedMessages) {
@@ -294,6 +304,7 @@ class MessagesProvider with ChangeNotifier {
void addMessage( void addMessage(
Message message, { Message message, {
String Function(String name)? contactLookup, String Function(String name)? contactLookup,
MessageContactLocation? contactLocationSnapshot,
}) { }) {
// Always enhance message with SAR parser to detect SAR markers // Always enhance message with SAR parser to detect SAR markers
var enhancedMessage = SarMessageParser.enhanceMessage(message); var enhancedMessage = SarMessageParser.enhanceMessage(message);
@@ -384,6 +395,9 @@ class MessagesProvider with ChangeNotifier {
} }
_messages.add(finalMessage); _messages.add(finalMessage);
if (contactLocationSnapshot != null) {
_messageContactLocations[finalMessage.id] = contactLocationSnapshot;
}
// If it's a SAR marker message, extract and store the marker // If it's a SAR marker message, extract and store the marker
if (finalMessage.isSarMarker) { if (finalMessage.isSarMarker) {
@@ -570,7 +584,10 @@ class MessagesProvider with ChangeNotifier {
/// Persist messages to storage (async, non-blocking) /// Persist messages to storage (async, non-blocking)
Future<void> _persistMessages() async { Future<void> _persistMessages() async {
try { try {
await _storageService.saveMessages(_messages); await _storageService.saveMessages(
_messages,
messageContactLocations: _messageContactLocations,
);
} catch (e) { } catch (e) {
debugPrint('❌ [MessagesProvider] Error persisting messages: $e'); debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
} }
@@ -685,6 +702,7 @@ class MessagesProvider with ChangeNotifier {
} }
_messageContactMap.remove(messageId); _messageContactMap.remove(messageId);
_groupedMessageMapping.remove(messageId); _groupedMessageMapping.remove(messageId);
_messageContactLocations.remove(messageId);
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted'); debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
@@ -713,6 +731,7 @@ class MessagesProvider with ChangeNotifier {
void clearMessages() { void clearMessages() {
_messages.clear(); _messages.clear();
_sarMarkers.clear(); _sarMarkers.clear();
_messageContactLocations.clear();
_persistMessages(); _persistMessages();
notifyListeners(); notifyListeners();
} }
@@ -727,6 +746,7 @@ class MessagesProvider with ChangeNotifier {
void clearAll() { void clearAll() {
_messages.clear(); _messages.clear();
_sarMarkers.clear(); _sarMarkers.clear();
_messageContactLocations.clear();
_persistMessages(); _persistMessages();
notifyListeners(); notifyListeners();
} }

View File

@@ -403,25 +403,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
channelCount: 2, channelCount: 2,
); );
final sarMessages = SampleDataGenerator.generateSarMarkerMessages( final sampleMessages = SampleDataGenerator.generateAllMessages(
centerLocation: centerLocation, centerLocation: centerLocation,
l10n: l10n, l10n: l10n,
foundPersonCount: 2, foundPersonCount: 2,
fireCount: 1, fireCount: 1,
stagingCount: 1, stagingCount: 1,
objectCount: 1, objectCount: 1,
);
final channelMessages = SampleDataGenerator.generateChannelMessages(
centerLocation: centerLocation,
l10n: l10n,
generalChannelMessages: 8, generalChannelMessages: 8,
emergencyChannelMessages: 5, emergencyChannelMessages: 5,
); );
// Combine all messages
final allMessages = [...sarMessages, ...channelMessages];
// Add to providers // Add to providers
final contactsProvider = Provider.of<ContactsProvider>( final contactsProvider = Provider.of<ContactsProvider>(
context, context,
@@ -433,7 +425,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
); );
contactsProvider.addContacts(contacts); contactsProvider.addContacts(contacts);
messagesProvider.addMessages(allMessages); for (final message in sampleMessages.messages) {
messagesProvider.addMessage(
message,
contactLocationSnapshot: sampleMessages.contactLocations[message.id],
);
}
if (!mounted) return; if (!mounted) return;
@@ -446,8 +443,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
AppLocalizations.of(context)!.loadedSampleData( AppLocalizations.of(context)!.loadedSampleData(
teamCount, teamCount,
channelCount, channelCount,
sarMessages.length, sampleMessages.messages.where((m) => m.isSarMarker).length,
channelMessages.length, sampleMessages.messages.length,
), ),
), ),
backgroundColor: Colors.green, backgroundColor: Colors.green,

View File

@@ -31,17 +31,22 @@ class MeshMapNode {
} }
class MeshMapNodesService { class MeshMapNodesService {
static const String _nodesEndpoint = 'https://api.meshcore.nz/api/v1/map/nodes'; static const String _nodesEndpoint =
'https://api.meshcore.nz/api/v1/map/nodes';
static const Duration _cacheTtl = Duration(minutes: 2); static const Duration _cacheTtl = Duration(minutes: 2);
static const Duration traceCacheTtl = Duration(minutes: 10);
static List<MeshMapNode>? _cachedNodes; static List<MeshMapNode>? _cachedNodes;
static DateTime? _cachedAt; static DateTime? _cachedAt;
static Future<List<MeshMapNode>> fetchNodes({bool forceRefresh = false}) async { static Future<List<MeshMapNode>> fetchNodes({
bool forceRefresh = false,
Duration cacheTtl = _cacheTtl,
}) async {
final now = DateTime.now(); final now = DateTime.now();
if (!forceRefresh && if (!forceRefresh &&
_cachedNodes != null && _cachedNodes != null &&
_cachedAt != null && _cachedAt != null &&
now.difference(_cachedAt!) < _cacheTtl) { now.difference(_cachedAt!) < cacheTtl) {
return _cachedNodes!; return _cachedNodes!;
} }
@@ -58,7 +63,9 @@ class MeshMapNodesService {
final nodes = nodesRaw final nodes = nodesRaw
.whereType<Map<String, dynamic>>() .whereType<Map<String, dynamic>>()
.map(MeshMapNode.fromJson) .map(MeshMapNode.fromJson)
.where((n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0) .where(
(n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0,
)
.toList(); .toList();
_cachedNodes = nodes; _cachedNodes = nodes;

View File

@@ -2,15 +2,21 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/message.dart'; import '../models/message.dart';
import '../models/message_contact_location.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
/// Service for persisting messages to local storage /// Service for persisting messages to local storage
class MessageStorageService { class MessageStorageService {
static const String _messagesKey = 'stored_messages'; static const String _messagesKey = 'stored_messages';
static const String _messageContactLocationsKey =
'stored_message_contact_locations';
static const int _maxStoredMessages = 1000; // Store up to 1000 messages static const int _maxStoredMessages = 1000; // Store up to 1000 messages
/// Save messages to persistent storage /// Save messages to persistent storage
Future<void> saveMessages(List<Message> messages) async { Future<void> saveMessages(
List<Message> messages, {
Map<String, MessageContactLocation> messageContactLocations = const {},
}) async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -24,6 +30,19 @@ class MessageStorageService {
final jsonString = jsonEncode(limitedList); final jsonString = jsonEncode(limitedList);
await prefs.setString(_messagesKey, jsonString); await prefs.setString(_messagesKey, jsonString);
final retainedMessageIds = limitedList
.map((entry) => entry['id'] as String)
.toSet();
final locationJson = <String, dynamic>{};
for (final entry in messageContactLocations.entries) {
if (retainedMessageIds.contains(entry.key)) {
locationJson[entry.key] = entry.value.toJson();
}
}
await prefs.setString(
_messageContactLocationsKey,
jsonEncode(locationJson),
);
debugPrint( debugPrint(
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage', '✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
@@ -33,6 +52,36 @@ class MessageStorageService {
} }
} }
Future<Map<String, MessageContactLocation>> loadMessageContactLocations()
async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageContactLocationsKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! Map<String, dynamic>) {
return const {};
}
final result = <String, MessageContactLocation>{};
for (final entry in decoded.entries) {
final value = entry.value;
if (value is! Map<String, dynamic>) continue;
final snapshot = MessageContactLocation.fromJson(value);
if (snapshot != null) {
result[entry.key] = snapshot;
}
}
return result;
} catch (e) {
debugPrint('❌ [MessageStorage] Error loading contact locations: $e');
return const {};
}
}
/// Load messages from persistent storage /// Load messages from persistent storage
Future<List<Message>> loadMessages() async { Future<List<Message>> loadMessages() async {
try { try {
@@ -66,6 +115,7 @@ class MessageStorageService {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.remove(_messagesKey); await prefs.remove(_messagesKey);
await prefs.remove(_messageContactLocationsKey);
debugPrint('✅ [MessageStorage] Cleared all stored messages'); debugPrint('✅ [MessageStorage] Cleared all stored messages');
} catch (e) { } catch (e) {
debugPrint('❌ [MessageStorage] Error clearing messages: $e'); debugPrint('❌ [MessageStorage] Error clearing messages: $e');

View File

@@ -4,12 +4,66 @@ import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/message.dart'; import '../models/message.dart';
import '../models/message_contact_location.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
/// Generates sample data for testing/demo purposes /// Generates sample data for testing/demo purposes
class SampleDataGenerator { class SampleDataGenerator {
static final Random _random = Random(); static final Random _random = Random();
static MessageContactLocation _sampleSnapshot({
required LatLng location,
required DateTime receivedAt,
required String source,
}) {
return MessageContactLocation(
location: location,
source: source,
capturedAt: receivedAt,
sourceTimestamp: receivedAt.subtract(const Duration(minutes: 2)),
);
}
static LatLng _randomNearbyLocation(LatLng center, double spread) {
final latOffset = (_random.nextDouble() - 0.5) * spread;
final lonOffset = (_random.nextDouble() - 0.5) * spread;
return LatLng(center.latitude + latOffset, center.longitude + lonOffset);
}
static SampleMessageBatch generateAllMessages({
required LatLng centerLocation,
required AppLocalizations l10n,
int foundPersonCount = 2,
int fireCount = 1,
int stagingCount = 1,
int objectCount = 1,
int generalChannelMessages = 8,
int emergencyChannelMessages = 5,
}) {
final sarBatch = generateSarMarkerMessages(
centerLocation: centerLocation,
l10n: l10n,
foundPersonCount: foundPersonCount,
fireCount: fireCount,
stagingCount: stagingCount,
objectCount: objectCount,
);
final channelBatch = generateChannelMessages(
centerLocation: centerLocation,
l10n: l10n,
generalChannelMessages: generalChannelMessages,
emergencyChannelMessages: emergencyChannelMessages,
);
return SampleMessageBatch(
messages: [...sarBatch.messages, ...channelBatch.messages],
contactLocations: {
...sarBatch.contactLocations,
...channelBatch.contactLocations,
},
);
}
/// Generate sample contacts around a center location /// Generate sample contacts around a center location
static List<Contact> generateContacts({ static List<Contact> generateContacts({
required LatLng centerLocation, required LatLng centerLocation,
@@ -113,7 +167,7 @@ class SampleDataGenerator {
} }
/// Generate sample SAR markers around a center location /// Generate sample SAR markers around a center location
static List<Message> generateSarMarkerMessages({ static SampleMessageBatch generateSarMarkerMessages({
required LatLng centerLocation, required LatLng centerLocation,
required AppLocalizations l10n, required AppLocalizations l10n,
int foundPersonCount = 2, int foundPersonCount = 2,
@@ -122,6 +176,7 @@ class SampleDataGenerator {
int objectCount = 1, int objectCount = 1,
}) { }) {
final messages = <Message>[]; final messages = <Message>[];
final contactLocations = <String, MessageContactLocation>{};
final now = DateTime.now(); final now = DateTime.now();
int messageId = 1; int messageId = 1;
@@ -137,7 +192,7 @@ class SampleDataGenerator {
); );
final timestamp = now.subtract(Duration(minutes: 10 + i * 5)); final timestamp = now.subtract(Duration(minutes: 10 + i * 5));
messages.add(Message( final message = Message(
id: 'sample_fp_$messageId', id: 'sample_fp_$messageId',
messageType: MessageType.contact, messageType: MessageType.contact,
senderPublicKeyPrefix: senderKey.sublist(0, 6), senderPublicKeyPrefix: senderKey.sublist(0, 6),
@@ -150,7 +205,13 @@ class SampleDataGenerator {
sarGpsCoordinates: LatLng(lat, lon), sarGpsCoordinates: LatLng(lat, lon),
sarCustomEmoji: '🧑', sarCustomEmoji: '🧑',
senderName: l10n.sampleTeamMember, senderName: l10n.sampleTeamMember,
)); );
messages.add(message);
contactLocations[message.id] = _sampleSnapshot(
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.006),
receivedAt: timestamp,
source: 'telemetry',
);
messageId++; messageId++;
} }
@@ -166,7 +227,7 @@ class SampleDataGenerator {
); );
final timestamp = now.subtract(Duration(minutes: 20 + i * 5)); final timestamp = now.subtract(Duration(minutes: 20 + i * 5));
messages.add(Message( final message = Message(
id: 'sample_fire_$messageId', id: 'sample_fire_$messageId',
messageType: MessageType.contact, messageType: MessageType.contact,
senderPublicKeyPrefix: senderKey.sublist(0, 6), senderPublicKeyPrefix: senderKey.sublist(0, 6),
@@ -179,7 +240,13 @@ class SampleDataGenerator {
sarGpsCoordinates: LatLng(lat, lon), sarGpsCoordinates: LatLng(lat, lon),
sarCustomEmoji: '🔥', sarCustomEmoji: '🔥',
senderName: l10n.sampleScout, senderName: l10n.sampleScout,
)); );
messages.add(message);
contactLocations[message.id] = _sampleSnapshot(
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.008),
receivedAt: timestamp,
source: 'advert',
);
messageId++; messageId++;
} }
@@ -195,7 +262,7 @@ class SampleDataGenerator {
); );
final timestamp = now.subtract(Duration(minutes: 30 + i * 5)); final timestamp = now.subtract(Duration(minutes: 30 + i * 5));
messages.add(Message( final message = Message(
id: 'sample_staging_$messageId', id: 'sample_staging_$messageId',
messageType: MessageType.contact, messageType: MessageType.contact,
senderPublicKeyPrefix: senderKey.sublist(0, 6), senderPublicKeyPrefix: senderKey.sublist(0, 6),
@@ -208,7 +275,13 @@ class SampleDataGenerator {
sarGpsCoordinates: LatLng(lat, lon), sarGpsCoordinates: LatLng(lat, lon),
sarCustomEmoji: '🏕️', sarCustomEmoji: '🏕️',
senderName: l10n.sampleBase, senderName: l10n.sampleBase,
)); );
messages.add(message);
contactLocations[message.id] = _sampleSnapshot(
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.01),
receivedAt: timestamp,
source: 'advert',
);
messageId++; messageId++;
} }
@@ -231,24 +304,34 @@ class SampleDataGenerator {
l10n.sampleObjectTrailMarker, l10n.sampleObjectTrailMarker,
]; ];
messages.add(Message( final message = Message(
id: 'sample_object_$messageId', id: 'sample_object_$messageId',
messageType: MessageType.contact, messageType: MessageType.contact,
senderPublicKeyPrefix: senderKey.sublist(0, 6), senderPublicKeyPrefix: senderKey.sublist(0, 6),
pathLen: 1, pathLen: 1,
textType: MessageTextType.plain, textType: MessageTextType.plain,
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000, senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
text: 'S:📦:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}${notes[i % notes.length]}', text:
'S:📦:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}${notes[i % notes.length]}',
receivedAt: timestamp, receivedAt: timestamp,
isSarMarker: true, isSarMarker: true,
sarGpsCoordinates: LatLng(lat, lon), sarGpsCoordinates: LatLng(lat, lon),
sarCustomEmoji: '📦', sarCustomEmoji: '📦',
senderName: l10n.sampleSearcher, senderName: l10n.sampleSearcher,
)); );
messages.add(message);
contactLocations[message.id] = _sampleSnapshot(
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.007),
receivedAt: timestamp,
source: 'telemetry',
);
messageId++; messageId++;
} }
return messages; return SampleMessageBatch(
messages: messages,
contactLocations: contactLocations,
);
} }
/// Generate sample map drawings /// Generate sample map drawings
@@ -272,7 +355,9 @@ class SampleDataGenerator {
'id': 'sample_line_${now.millisecondsSinceEpoch}', 'id': 'sample_line_${now.millisecondsSinceEpoch}',
'color': Colors.blue.toARGB32(), 'color': Colors.blue.toARGB32(),
'createdAt': now.subtract(const Duration(minutes: 15)).toIso8601String(), 'createdAt': now.subtract(const Duration(minutes: 15)).toIso8601String(),
'points': linePoints.map((p) => {'lat': p.latitude, 'lon': p.longitude}).toList(), 'points': linePoints
.map((p) => {'lat': p.latitude, 'lon': p.longitude})
.toList(),
'sender': l10n.sampleTeamMember, 'sender': l10n.sampleTeamMember,
}); });
@@ -297,7 +382,7 @@ class SampleDataGenerator {
} }
/// Generate sample channel messages for public channels /// Generate sample channel messages for public channels
static List<Message> generateChannelMessages({ static SampleMessageBatch generateChannelMessages({
LatLng? centerLocation, LatLng? centerLocation,
required AppLocalizations l10n, required AppLocalizations l10n,
int generalChannelMessages = 8, int generalChannelMessages = 8,
@@ -306,6 +391,7 @@ class SampleDataGenerator {
// Use provided location or default to Ljubljana, Slovenia // Use provided location or default to Ljubljana, Slovenia
final center = centerLocation ?? const LatLng(46.0569, 14.5058); final center = centerLocation ?? const LatLng(46.0569, 14.5058);
final messages = <Message>[]; final messages = <Message>[];
final contactLocations = <String, MessageContactLocation>{};
final now = DateTime.now(); final now = DateTime.now();
int messageId = 1000; // Start with high ID to avoid conflicts int messageId = 1000; // Start with high ID to avoid conflicts
@@ -352,7 +438,11 @@ class SampleDataGenerator {
]; ];
// Generate General channel messages // Generate General channel messages
for (int i = 0; i < generalChannelMessages && i < generalMessages.length; i++) { for (
int i = 0;
i < generalChannelMessages && i < generalMessages.length;
i++
) {
final senderKey = Uint8List.fromList( final senderKey = Uint8List.fromList(
List.generate(32, (_) => _random.nextInt(256)), List.generate(32, (_) => _random.nextInt(256)),
); );
@@ -361,7 +451,7 @@ class SampleDataGenerator {
final minutesAgo = 120 - (i * 15) - _random.nextInt(10); final minutesAgo = 120 - (i * 15) - _random.nextInt(10);
final timestamp = now.subtract(Duration(minutes: minutesAgo)); final timestamp = now.subtract(Duration(minutes: minutesAgo));
messages.add(Message( final message = Message(
id: 'sample_general_$messageId', id: 'sample_general_$messageId',
messageType: MessageType.channel, messageType: MessageType.channel,
channelIdx: 0, // General channel channelIdx: 0, // General channel
@@ -372,12 +462,22 @@ class SampleDataGenerator {
text: generalMessages[i], text: generalMessages[i],
receivedAt: timestamp, receivedAt: timestamp,
senderName: teamNames[_random.nextInt(teamNames.length)], senderName: teamNames[_random.nextInt(teamNames.length)],
)); );
messages.add(message);
contactLocations[message.id] = _sampleSnapshot(
location: _randomNearbyLocation(center, 0.018),
receivedAt: timestamp,
source: i.isEven ? 'telemetry' : 'advert',
);
messageId++; messageId++;
} }
// Generate Emergency channel messages // Generate Emergency channel messages
for (int i = 0; i < emergencyChannelMessages && i < emergencyMessages.length; i++) { for (
int i = 0;
i < emergencyChannelMessages && i < emergencyMessages.length;
i++
) {
final senderKey = Uint8List.fromList( final senderKey = Uint8List.fromList(
List.generate(32, (_) => _random.nextInt(256)), List.generate(32, (_) => _random.nextInt(256)),
); );
@@ -386,7 +486,7 @@ class SampleDataGenerator {
final minutesAgo = 60 - (i * 10) - _random.nextInt(5); final minutesAgo = 60 - (i * 10) - _random.nextInt(5);
final timestamp = now.subtract(Duration(minutes: minutesAgo)); final timestamp = now.subtract(Duration(minutes: minutesAgo));
messages.add(Message( final message = Message(
id: 'sample_emergency_$messageId', id: 'sample_emergency_$messageId',
messageType: MessageType.channel, messageType: MessageType.channel,
channelIdx: 1, // Emergency channel channelIdx: 1, // Emergency channel
@@ -397,10 +497,29 @@ class SampleDataGenerator {
text: emergencyMessages[i], text: emergencyMessages[i],
receivedAt: timestamp, receivedAt: timestamp,
senderName: teamNames[_random.nextInt(teamNames.length)], senderName: teamNames[_random.nextInt(teamNames.length)],
)); );
messages.add(message);
contactLocations[message.id] = _sampleSnapshot(
location: _randomNearbyLocation(center, 0.012),
receivedAt: timestamp,
source: i.isEven ? 'advert' : 'telemetry',
);
messageId++; messageId++;
} }
return messages; return SampleMessageBatch(
messages: messages,
contactLocations: contactLocations,
);
} }
} }
class SampleMessageBatch {
final List<Message> messages;
final Map<String, MessageContactLocation> contactLocations;
const SampleMessageBatch({
required this.messages,
required this.contactLocations,
});
}

View File

@@ -23,8 +23,10 @@ import '../../utils/key_comparison.dart';
import '../../utils/voice_message_parser.dart'; import '../../utils/voice_message_parser.dart';
import '../../utils/image_message_parser.dart'; import '../../utils/image_message_parser.dart';
import '../../utils/tictactoe_message_parser.dart'; import '../../utils/tictactoe_message_parser.dart';
import '../../utils/avatar_label_helper.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../utils/message_extensions.dart'; import '../../utils/message_extensions.dart';
import '../common/contact_avatar.dart';
import 'voice_message_bubble.dart'; import 'voice_message_bubble.dart';
import 'image_message_bubble.dart'; import 'image_message_bubble.dart';
import 'tictactoe_message_bubble.dart'; import 'tictactoe_message_bubble.dart';
@@ -62,6 +64,103 @@ class _MessageBubbleState extends State<MessageBubble> {
bool _isExpanded = false; bool _isExpanded = false;
bool _showReceivedStats = false; bool _showReceivedStats = false;
Widget _buildHeaderAvatar(
BuildContext context, {
required bool isOwnMessage,
required bool isChannelMessage,
required dynamic senderContact,
required String displayName,
}) {
if (isOwnMessage) {
return CircleAvatar(
radius: 10.5,
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: Icon(
Icons.account_circle,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
);
}
if (senderContact is Contact) {
return ContactAvatar(
contact: senderContact,
radius: 10.5,
displayName: displayName,
);
}
final background = isChannelMessage
? Colors.teal.withValues(alpha: 0.16)
: Theme.of(context).colorScheme.surfaceContainerHighest;
final foreground = isChannelMessage
? Colors.teal.shade800
: Theme.of(context).colorScheme.onSurfaceVariant;
return CircleAvatar(
radius: 10.5,
backgroundColor: background,
child: Text(
AvatarLabelHelper.buildLabel(displayName),
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: foreground,
letterSpacing: -0.2,
),
),
);
}
Widget _buildBubbleMetaFooter(
BuildContext context, {
required Message message,
required bool isOwnMessage,
required bool isSarMarker,
}) {
final metaColor = Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.68);
final items = <Widget>[
Text(
message.getLocalizedTimeAgo(context),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: metaColor,
fontWeight: FontWeight.w500,
),
),
];
if (!isOwnMessage && !isSarMarker && message.pathLen < 255) {
items.addAll([
Text(
'',
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: metaColor),
),
Icon(Icons.alt_route, size: 11, color: metaColor),
const SizedBox(width: 3),
Text(
message.pathLen == 0 ? 'direct' : '${message.pathLen}hop',
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: metaColor),
),
]);
}
return Padding(
padding: const EdgeInsets.only(left: 6, right: 6, top: 1, bottom: 18),
child: Align(
alignment: Alignment.centerRight,
child: Row(mainAxisSize: MainAxisSize.min, children: items),
),
);
}
@override @override
void didUpdateWidget(MessageBubble oldWidget) { void didUpdateWidget(MessageBubble oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
@@ -356,6 +455,7 @@ class _MessageBubbleState extends State<MessageBubble> {
final radioSf = connectionProvider.deviceInfo.radioSf; final radioSf = connectionProvider.deviceInfo.radioSf;
final radioCr = connectionProvider.deviceInfo.radioCr; final radioCr = connectionProvider.deviceInfo.radioCr;
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
final messagesProvider = context.read<MessagesProvider>();
final voiceProvider = context.read<VoiceProvider>(); final voiceProvider = context.read<VoiceProvider>();
final imageProvider = context.read<ip.ImageProvider>(); final imageProvider = context.read<ip.ImageProvider>();
final selfPublicKey = connectionProvider.deviceInfo.publicKey; final selfPublicKey = connectionProvider.deviceInfo.publicKey;
@@ -397,6 +497,10 @@ class _MessageBubbleState extends State<MessageBubble> {
recipientName = recipientContact?.advName; recipientName = recipientContact?.advName;
} }
final senderLocationSnapshot = messagesProvider.getMessageContactLocation(
widget.message.id,
);
final envelope = VoiceEnvelope.tryParseText(widget.message.text); final envelope = VoiceEnvelope.tryParseText(widget.message.text);
final legacyVoicePacket = VoicePacket.tryParseText(widget.message.text); final legacyVoicePacket = VoicePacket.tryParseText(widget.message.text);
final voiceSession = widget.message.voiceId != null final voiceSession = widget.message.voiceId != null
@@ -496,6 +600,9 @@ class _MessageBubbleState extends State<MessageBubble> {
'Used flood fallback: ${widget.message.usedFloodFallback}', 'Used flood fallback: ${widget.message.usedFloodFallback}',
'Sender key prefix: ${senderPrefixHex ?? '-'}', 'Sender key prefix: ${senderPrefixHex ?? '-'}',
'Sender name: ${senderName ?? widget.message.senderName ?? '-'}', 'Sender name: ${senderName ?? widget.message.senderName ?? '-'}',
'Sender location at receipt: ${senderLocationSnapshot?.formattedCoordinates ?? '-'}',
'Sender location source: ${senderLocationSnapshot?.technicalSourceLabel ?? '-'}',
'Sender location timestamp: ${senderLocationSnapshot?.sourceTimestamp?.toIso8601String() ?? '-'}',
'Recipient key prefix: ${recipientPrefixHex ?? '-'}', 'Recipient key prefix: ${recipientPrefixHex ?? '-'}',
'Recipient name: ${recipientName ?? '-'}', 'Recipient name: ${recipientName ?? '-'}',
'Drawing flag: ${widget.message.isDrawing}', 'Drawing flag: ${widget.message.isDrawing}',
@@ -1850,7 +1957,7 @@ class _MessageBubbleState extends State<MessageBubble> {
? null ? null
: () => _showMessageOptions(context), : () => _showMessageOptions(context),
child: Container( child: Container(
margin: const EdgeInsets.only(bottom: 8), margin: EdgeInsets.zero,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: widget.isHighlighted color: widget.isHighlighted
@@ -1993,15 +2100,6 @@ class _MessageBubbleState extends State<MessageBubble> {
], ],
), ),
), ),
const Spacer(),
Text(
message.getLocalizedTimeAgo(context),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: isSarMarker
? FontWeight.w600
: FontWeight.normal,
),
),
], ],
), ),
@@ -2023,16 +2121,13 @@ class _MessageBubbleState extends State<MessageBubble> {
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
), ),
if (isOwnMessage) _buildHeaderAvatar(
Icon( context,
Icons.account_circle, isOwnMessage: isOwnMessage,
size: 16, isChannelMessage: message.isChannelMessage,
color: Theme.of(context).colorScheme.primary, senderContact: senderContact,
) displayName: displayName,
else if (message.isChannelMessage) ),
const Icon(Icons.tag, size: 16)
else
const Icon(Icons.person, size: 16),
const SizedBox(width: 4), const SizedBox(width: 4),
Expanded( Expanded(
child: Row( child: Row(
@@ -2151,83 +2246,140 @@ class _MessageBubbleState extends State<MessageBubble> {
], ],
), ),
), ),
// Time for regular messages (not shown for SAR/drawing as it's already above)
if (!isSarMarker && !message.isDrawing) ...[
const SizedBox(width: 8),
// Hop count indicator for received messages
if (!isOwnMessage && message.pathLen < 255) ...[
const SizedBox(width: 4),
Icon(
Icons.alt_route,
size: 11,
color: Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.6),
),
const SizedBox(width: 2),
Text(
message.pathLen == 0
? 'direct'
: '${message.pathLen}hop',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.6),
),
),
const SizedBox(width: 4),
],
Text(
message.getLocalizedTimeAgo(context),
style: Theme.of(context).textTheme.labelSmall,
),
],
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// SAR marker content // SAR marker content
if (isSarMarker && message.sarMarkerType != null) ...[ if (isSarMarker && message.sarMarkerType != null) ...[
Column( Container(
crossAxisAlignment: CrossAxisAlignment.start, width: double.infinity,
children: [ padding: const EdgeInsets.all(12),
Row( decoration: BoxDecoration(
children: [ color: Colors.white.withValues(
Text( alpha: isDarkMode ? 0.06 : 0.42,
message.sarCustomEmoji ?? ),
message.sarMarkerType!.emoji, borderRadius: BorderRadius.circular(14),
style: const TextStyle(fontSize: 32), border: Border.all(
), color: _getSarMarkerBorderColor(
const SizedBox(width: 10), context,
Expanded( isDarkMode,
child: Text( ).withValues(alpha: 0.22),
message.sarNotes != null && ),
message.sarNotes!.isNotEmpty ),
? message.sarNotes! child: Column(
: message.sarMarkerType!.getLocalizedName( crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: _getSarMarkerBorderColor(
context,
isDarkMode,
).withValues(alpha: isDarkMode ? 0.2 : 0.12),
borderRadius: BorderRadius.circular(16),
),
alignment: Alignment.center,
child: Text(
message.sarCustomEmoji ??
message.sarMarkerType!.emoji,
style: const TextStyle(fontSize: 30, height: 1),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
message.sarMarkerType!.getLocalizedName(
context, context,
), ),
style: Theme.of(context).textTheme.titleSmall style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold), ?.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
),
),
if (message.sarNotes != null &&
message.sarNotes!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
message.sarNotes!,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
height: 1.25,
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.86),
),
),
],
],
),
),
if (!widget.isCompact)
Padding(
padding: const EdgeInsets.only(left: 8, top: 2),
child: Icon(
Icons.chevron_right_rounded,
size: 20,
color: _getSarMarkerBorderColor(
context,
isDarkMode,
),
),
),
],
),
if (message.sarGpsCoordinates != null) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: Colors.black.withValues(
alpha: isDarkMode ? 0.18 : 0.05,
),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Icon(
Icons.place_outlined,
size: 15,
color: _getSarMarkerBorderColor(
context,
isDarkMode,
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
'${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}',
style: Theme.of(context).textTheme.labelMedium
?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.w700,
letterSpacing: 0.15,
),
),
),
],
), ),
), ),
if (!widget.isCompact)
Icon(
Icons.chevron_right,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
], ],
),
if (message.sarGpsCoordinates != null) ...[
const SizedBox(height: 6),
Text(
'${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}',
style: Theme.of(context).textTheme.labelMedium
?.copyWith(fontFamily: 'monospace'),
),
], ],
], ),
), ),
] ]
// Drawing message content (skip in compact mode - drawings hidden) // Drawing message content (skip in compact mode - drawings hidden)
@@ -2610,15 +2762,30 @@ class _MessageBubbleState extends State<MessageBubble> {
), ),
); );
final bubbleWithMeta = Column(
crossAxisAlignment: isOwnMessage
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
bubble,
_buildBubbleMetaFooter(
context,
message: message,
isOwnMessage: isOwnMessage,
isSarMarker: isSarMarker,
),
],
);
if (!shouldFloatBubble) { if (!shouldFloatBubble) {
return bubble; return bubbleWithMeta;
} }
return Row( return Row(
mainAxisAlignment: isOwnMessage mainAxisAlignment: isOwnMessage
? MainAxisAlignment.end ? MainAxisAlignment.end
: MainAxisAlignment.start, : MainAxisAlignment.start,
children: [Flexible(child: bubble)], children: [Flexible(child: bubbleWithMeta)],
); );
} }
} }

View File

@@ -30,7 +30,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
Future<_TraceResult> _loadTrace() async { Future<_TraceResult> _loadTrace() async {
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final nodes = await MeshMapNodesService.fetchNodes(); final nodes = await MeshMapNodesService.fetchNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl,
);
final packetPath = _extractPathFromPacketLogs( final packetPath = _extractPathFromPacketLogs(
logs: connectionProvider.bleService.packetLogs, logs: connectionProvider.bleService.packetLogs,
message: widget.message, message: widget.message,
@@ -166,23 +168,31 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
child: hasMapPath child: hasMapPath
? flutter_map.FlutterMap( ? flutter_map.FlutterMap(
options: flutter_map.MapOptions( options: flutter_map.MapOptions(
initialCameraFit: flutter_map.CameraFit.bounds( initialCameraFit:
bounds: flutter_map.LatLngBounds.fromPoints(mapPoints), flutter_map.CameraFit.bounds(
padding: const EdgeInsets.all(28), bounds:
), flutter_map
.LatLngBounds.fromPoints(
mapPoints,
),
padding: const EdgeInsets.all(28),
),
), ),
children: [ children: [
flutter_map.TileLayer( flutter_map.TileLayer(
urlTemplate: urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png', 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.meshcore.sar', userAgentPackageName:
'com.meshcore.sar',
), ),
flutter_map.PolylineLayer( flutter_map.PolylineLayer(
polylines: [ polylines: [
flutter_map.Polyline( flutter_map.Polyline(
points: mapPoints, points: mapPoints,
strokeWidth: 4, strokeWidth: 4,
color: Theme.of(context).colorScheme.primary, color: Theme.of(
context,
).colorScheme.primary,
), ),
], ],
), ),
@@ -202,12 +212,14 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
height: 34, height: 34,
child: CircleAvatar( child: CircleAvatar(
radius: 16, radius: 16,
backgroundColor: entry.key == 0 backgroundColor:
entry.key == 0
? Colors.green ? Colors.green
: (entry.key == : (entry.key ==
trace trace.matchedPathNodes
.matchedPathNodes .whereType<
.whereType<MeshMapNode>() MeshMapNode
>()
.length - .length -
1 1
? Colors.red ? Colors.red
@@ -216,7 +228,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
'${entry.key + 1}', '${entry.key + 1}',
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.bold, fontWeight:
FontWeight.bold,
fontSize: 11, fontSize: 11,
), ),
), ),
@@ -228,7 +241,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
], ],
) )
: const Center( : const Center(
child: Text('Not enough geolocated nodes to draw path'), child: Text(
'Not enough geolocated nodes to draw path',
),
), ),
), ),
), ),
@@ -244,8 +259,13 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
), ),
if (relayNodes.isEmpty) if (relayNodes.isEmpty)
const Padding( const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: EdgeInsets.symmetric(
child: Text('No relay nodes could be matched for this message.'), horizontal: 16,
vertical: 8,
),
child: Text(
'No relay nodes could be matched for this message.',
),
), ),
...relayNodes.map( ...relayNodes.map(
(node) => ListTile( (node) => ListTile(
@@ -286,10 +306,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
MeshMapNode? _bestNodeForPrefix(List<MeshMapNode> nodes, String? prefixHex) { MeshMapNode? _bestNodeForPrefix(List<MeshMapNode> nodes, String? prefixHex) {
if (prefixHex == null || prefixHex.isEmpty) return null; if (prefixHex == null || prefixHex.isEmpty) return null;
final matches = nodes final matches =
.where((n) => n.publicKey.startsWith(prefixHex)) nodes.where((n) => n.publicKey.startsWith(prefixHex)).toList()
.toList() ..sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
..sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
return matches.isEmpty ? null : matches.first; return matches.isEmpty ? null : matches.first;
} }
@@ -298,7 +317,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
required Message message, required Message message,
}) { }) {
if (message.pathLen <= 0 || message.pathLen >= 255) return null; if (message.pathLen <= 0 || message.pathLen >= 255) return null;
final expectedPayloadType = message.messageType == MessageType.channel ? 0x05 : 0x02; final expectedPayloadType = message.messageType == MessageType.channel
? 0x05
: 0x02;
BlePacketLog? bestLog; BlePacketLog? bestLog;
var bestDeltaMs = 999999999; var bestDeltaMs = 999999999;
@@ -313,7 +334,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
if (pathLen != message.pathLen) continue; if (pathLen != message.pathLen) continue;
if (raw.length < 5 + pathLen) continue; if (raw.length < 5 + pathLen) continue;
final deltaMs = (log.timestamp.difference(message.receivedAt).inMilliseconds).abs(); final deltaMs =
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
if (deltaMs < bestDeltaMs) { if (deltaMs < bestDeltaMs) {
bestDeltaMs = deltaMs; bestDeltaMs = deltaMs;
bestLog = log; bestLog = log;
@@ -335,7 +357,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
final result = <MeshMapNode?>[]; final result = <MeshMapNode?>[];
for (var i = 0; i < pathHashes.length; i++) { for (var i = 0; i < pathHashes.length; i++) {
final hashHex = pathHashes[i].toRadixString(16).padLeft(2, '0'); final hashHex = pathHashes[i].toRadixString(16).padLeft(2, '0');
final candidates = nodes.where((n) => n.publicKey.startsWith(hashHex)).toList(); final candidates = nodes
.where((n) => n.publicKey.startsWith(hashHex))
.toList();
if (candidates.isEmpty) { if (candidates.isEmpty) {
result.add(null); result.add(null);
continue; continue;
@@ -343,7 +367,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
List<MeshMapNode> filtered = candidates; List<MeshMapNode> filtered = candidates;
if (i == 0 && senderPrefix != null) { if (i == 0 && senderPrefix != null) {
final senderMatches = filtered.where((n) => n.publicKey.startsWith(senderPrefix)).toList(); final senderMatches = filtered
.where((n) => n.publicKey.startsWith(senderPrefix))
.toList();
if (senderMatches.isNotEmpty) filtered = senderMatches; if (senderMatches.isNotEmpty) filtered = senderMatches;
} else if (i == pathHashes.length - 1 && recipientPrefix != null) { } else if (i == pathHashes.length - 1 && recipientPrefix != null) {
final recipientMatches = filtered final recipientMatches = filtered
@@ -366,7 +392,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
}) { }) {
if (relayCount <= 0 || sender == null || recipient == null) return const []; if (relayCount <= 0 || sender == null || recipient == null) return const [];
final candidates = nodes.where((n) { final candidates = nodes.where((n) {
if (sender.publicKey == n.publicKey || recipient.publicKey == n.publicKey) { if (sender.publicKey == n.publicKey ||
recipient.publicKey == n.publicKey) {
return false; return false;
} }
return true; return true;

View File

@@ -194,5 +194,43 @@ void main() {
} }
}, },
); );
test('builds message snapshot from latest valid telemetry', () {
final telemetryData = CayenneLppParser.createGpsData(
latitude: 45.0001,
longitude: 13.9999,
);
provider.updateTelemetry(publicKey.sublist(0, 6), telemetryData);
final contact = provider.findContactByKey(publicKey)!;
final snapshot = provider.buildMessageContactLocationSnapshot(
contact,
capturedAt: DateTime.now(),
);
expect(snapshot, isNotNull);
expect(snapshot!.source, equals('telemetry'));
expect(snapshot.location.latitude, closeTo(45.0001, 0.0001));
expect(snapshot.location.longitude, closeTo(13.9999, 0.0001));
});
test('builds message snapshot from advert when telemetry is invalid', () {
final invalidTelemetry = CayenneLppParser.createGpsData(
latitude: 0.0,
longitude: 0.0,
);
provider.updateTelemetry(publicKey.sublist(0, 6), invalidTelemetry);
final contact = provider.findContactByKey(publicKey)!;
final snapshot = provider.buildMessageContactLocationSnapshot(
contact,
capturedAt: DateTime.now(),
);
expect(snapshot, isNotNull);
expect(snapshot!.source, equals('advert'));
expect(snapshot.location.latitude, closeTo(46.0569, 0.000001));
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
});
}); });
} }

View File

@@ -1,13 +1,20 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/message.dart'; import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/models/message_contact_location.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart'; import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:meshcore_sar_app/utils/voice_message_parser.dart'; import 'package:meshcore_sar_app/utils/voice_message_parser.dart';
import 'package:latlong2/latlong.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
group('MessagesProvider voice detection', () { group('MessagesProvider voice detection', () {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('marks VE2 envelope messages as voice', () { test('marks VE2 envelope messages as voice', () {
final provider = MessagesProvider(); final provider = MessagesProvider();
final envelope = VoiceEnvelope( final envelope = VoiceEnvelope(
@@ -65,5 +72,39 @@ void main() {
expect(stored.isVoice, isTrue); expect(stored.isVoice, isTrue);
expect(stored.voiceId, equals('00112233')); expect(stored.voiceId, equals('00112233'));
}); });
test('persists received contact location snapshots', () async {
final provider = MessagesProvider();
final message = Message(
id: 'm3',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000002,
text: 'status update',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
);
provider.addMessage(
message,
contactLocationSnapshot: MessageContactLocation(
location: const LatLng(46.0569, 14.5058),
source: 'advert',
capturedAt: DateTime.now(),
sourceTimestamp: DateTime.now(),
),
);
await Future<void>.delayed(const Duration(milliseconds: 50));
final restoredProvider = MessagesProvider();
await restoredProvider.initialize();
final snapshot = restoredProvider.getMessageContactLocation('m3');
expect(snapshot, isNotNull);
expect(snapshot!.source, equals('advert'));
expect(snapshot.location.latitude, closeTo(46.0569, 0.000001));
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
});
}); });
} }