Fix RX advert data parsing

This commit is contained in:
Janez T
2026-03-14 16:34:31 +01:00
parent a3147e9d7c
commit 6e9e9e397d
22 changed files with 1636 additions and 683 deletions

View File

@@ -30,6 +30,7 @@ class MessageReceptionDetails {
final int? senderToReceiptMs; final int? senderToReceiptMs;
final int? estimatedTransmitMs; final int? estimatedTransmitMs;
final int? postTransmitDelayMs; final int? postTransmitDelayMs;
final int receivedCopies;
const MessageReceptionDetails({ const MessageReceptionDetails({
required this.capturedAt, required this.capturedAt,
@@ -40,6 +41,7 @@ class MessageReceptionDetails {
this.senderToReceiptMs, this.senderToReceiptMs,
this.estimatedTransmitMs, this.estimatedTransmitMs,
this.postTransmitDelayMs, this.postTransmitDelayMs,
this.receivedCopies = 1,
}); });
String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty
@@ -56,9 +58,60 @@ class MessageReceptionDetails {
'senderToReceiptMs': senderToReceiptMs, 'senderToReceiptMs': senderToReceiptMs,
'estimatedTransmitMs': estimatedTransmitMs, 'estimatedTransmitMs': estimatedTransmitMs,
'postTransmitDelayMs': postTransmitDelayMs, 'postTransmitDelayMs': postTransmitDelayMs,
'receivedCopies': receivedCopies,
}; };
} }
MessageReceptionDetails copyWith({
DateTime? capturedAt,
DateTime? packetLoggedAt,
int? rssiDbm,
double? snrDb,
List<int>? pathBytes,
int? senderToReceiptMs,
int? estimatedTransmitMs,
int? postTransmitDelayMs,
int? receivedCopies,
}) {
return MessageReceptionDetails(
capturedAt: capturedAt ?? this.capturedAt,
packetLoggedAt: packetLoggedAt ?? this.packetLoggedAt,
rssiDbm: rssiDbm ?? this.rssiDbm,
snrDb: snrDb ?? this.snrDb,
pathBytes: pathBytes ?? this.pathBytes,
senderToReceiptMs: senderToReceiptMs ?? this.senderToReceiptMs,
estimatedTransmitMs: estimatedTransmitMs ?? this.estimatedTransmitMs,
postTransmitDelayMs: postTransmitDelayMs ?? this.postTransmitDelayMs,
receivedCopies: receivedCopies ?? this.receivedCopies,
);
}
static MessageReceptionDetails mergeDuplicate({
MessageReceptionDetails? existing,
MessageReceptionDetails? incoming,
}) {
final base =
existing ??
incoming ??
MessageReceptionDetails(capturedAt: DateTime.now());
return base.copyWith(
capturedAt: incoming?.capturedAt ?? existing?.capturedAt,
packetLoggedAt: incoming?.packetLoggedAt ?? existing?.packetLoggedAt,
rssiDbm: incoming?.rssiDbm ?? existing?.rssiDbm,
snrDb: incoming?.snrDb ?? existing?.snrDb,
pathBytes: incoming?.pathBytes ?? existing?.pathBytes,
senderToReceiptMs:
incoming?.senderToReceiptMs ?? existing?.senderToReceiptMs,
estimatedTransmitMs:
incoming?.estimatedTransmitMs ?? existing?.estimatedTransmitMs,
postTransmitDelayMs:
incoming?.postTransmitDelayMs ?? existing?.postTransmitDelayMs,
receivedCopies:
(existing?.receivedCopies ?? 1) + (incoming?.receivedCopies ?? 1),
);
}
static MessageReceptionDetails? fromJson(Map<String, dynamic> json) { static MessageReceptionDetails? fromJson(Map<String, dynamic> json) {
final capturedAtMillis = json['capturedAtMillis']; final capturedAtMillis = json['capturedAtMillis'];
if (capturedAtMillis is! int) { if (capturedAtMillis is! int) {
@@ -81,6 +134,7 @@ class MessageReceptionDetails {
senderToReceiptMs: json['senderToReceiptMs'] as int?, senderToReceiptMs: json['senderToReceiptMs'] as int?,
estimatedTransmitMs: json['estimatedTransmitMs'] as int?, estimatedTransmitMs: json['estimatedTransmitMs'] as int?,
postTransmitDelayMs: json['postTransmitDelayMs'] as int?, postTransmitDelayMs: json['postTransmitDelayMs'] as int?,
receivedCopies: json['receivedCopies'] as int? ?? 1,
); );
} }
} }

View File

@@ -17,6 +17,7 @@ import '../services/nearest_router_selector.dart';
import '../services/packet_capture_storage_service.dart'; import '../services/packet_capture_storage_service.dart';
import '../services/path_history_service.dart'; import '../services/path_history_service.dart';
import '../services/route_hash_preferences.dart'; import '../services/route_hash_preferences.dart';
import '../services/notification_service.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/message.dart'; import '../models/message.dart';
import '../models/ble_packet_log.dart'; import '../models/ble_packet_log.dart';
@@ -59,6 +60,9 @@ class _DirectMessageRouteSession {
/// Main App Provider - coordinates all other providers /// Main App Provider - coordinates all other providers
class AppProvider with ChangeNotifier { class AppProvider with ChangeNotifier {
static const int _maxDirectPayloadHops = 3; static const int _maxDirectPayloadHops = 3;
static const double _lowBatteryThresholdPercent = 30.0;
static const double _lowBatteryResetThresholdPercent = 35.0;
static const Duration _lowBatteryCheckInterval = Duration(minutes: 5);
@visibleForTesting @visibleForTesting
static bool isDeletedChannelInfo( static bool isDeletedChannelInfo(
int channelIdx, int channelIdx,
@@ -88,6 +92,7 @@ class AppProvider with ChangeNotifier {
LocationTrackingService(); LocationTrackingService();
final PacketCaptureStorageService packetCaptureStorageService = final PacketCaptureStorageService packetCaptureStorageService =
PacketCaptureStorageService(); PacketCaptureStorageService();
final NotificationService _notificationService = NotificationService();
bool _isInitialized = false; bool _isInitialized = false;
bool get isInitialized => _isInitialized; bool get isInitialized => _isInitialized;
@@ -117,8 +122,6 @@ class AppProvider with ChangeNotifier {
bool get isVoiceNoiseSuppressionEnabled => _isVoiceNoiseSuppressionEnabled; bool get isVoiceNoiseSuppressionEnabled => _isVoiceNoiseSuppressionEnabled;
double _messageFontScale = 1.0; double _messageFontScale = 1.0;
double get messageFontScale => _messageFontScale; double get messageFontScale => _messageFontScale;
bool _autoAddDiscoveredContacts = false;
bool get autoAddDiscoveredContacts => _autoAddDiscoveredContacts;
bool _autoRouteRotationEnabled = bool _autoRouteRotationEnabled =
MessagingRoutePreferences.defaultAutoRouteRotationEnabled; MessagingRoutePreferences.defaultAutoRouteRotationEnabled;
bool get autoRouteRotationEnabled => _autoRouteRotationEnabled; bool get autoRouteRotationEnabled => _autoRouteRotationEnabled;
@@ -150,11 +153,13 @@ class AppProvider with ChangeNotifier {
_pendingMediaSwarmResponses = {}; _pendingMediaSwarmResponses = {};
bool _fastLocationScreenActive = false; bool _fastLocationScreenActive = false;
Timer? _packetCaptureFlushTimer; Timer? _packetCaptureFlushTimer;
Timer? _lowBatteryCheckTimer;
String? _lastPersistedPacketSignature; String? _lastPersistedPacketSignature;
bool _isPersistingPacketCapture = false; bool _isPersistingPacketCapture = false;
bool _wasDeviceConnected = false; bool _wasDeviceConnected = false;
bool _hasCompletedConnectionBootstrap = false; bool _hasCompletedConnectionBootstrap = false;
bool _isReconnectSyncInProgress = false; bool _isReconnectSyncInProgress = false;
final Set<String> _lowBatteryNotifiedNodeIds = <String>{};
AppProvider({ AppProvider({
required this.connectionProvider, required this.connectionProvider,
@@ -181,10 +186,10 @@ class AppProvider with ChangeNotifier {
_loadVoiceEchoCancellationEnabled(); _loadVoiceEchoCancellationEnabled();
_loadVoiceNoiseSuppressionEnabled(); _loadVoiceNoiseSuppressionEnabled();
_loadMessageFontScale(); _loadMessageFontScale();
_loadAutoAddDiscoveredContacts();
_loadMessagingRouteSettings(); _loadMessagingRouteSettings();
unawaited(_pathHistoryService.initialize()); unawaited(_pathHistoryService.initialize());
_startPacketCapturePersistence(); _startPacketCapturePersistence();
_startLowBatteryWatcher();
_syncDrawingsOnStartup(); // Sync drawings immediately after providers load _syncDrawingsOnStartup(); // Sync drawings immediately after providers load
_isInitialized = true; _isInitialized = true;
} }
@@ -197,6 +202,86 @@ class AppProvider with ChangeNotifier {
unawaited(_flushPacketCaptureLogs()); unawaited(_flushPacketCaptureLogs());
} }
void _startLowBatteryWatcher() {
_lowBatteryCheckTimer?.cancel();
_lowBatteryCheckTimer = Timer.periodic(_lowBatteryCheckInterval, (_) {
unawaited(_checkLowBatteryAlerts());
});
unawaited(_checkLowBatteryAlerts());
}
Future<void> _checkLowBatteryAlerts() async {
final recoveredIds = <String>{};
final deviceBattery = connectionProvider.deviceInfo.batteryPercent;
if (deviceBattery != null &&
deviceBattery > _lowBatteryResetThresholdPercent) {
recoveredIds.add('device');
}
for (final contact in contactsProvider.contacts) {
if (contact.isChannel) continue;
final battery = contact.displayBattery;
if (battery == null) continue;
if (battery > _lowBatteryResetThresholdPercent) {
recoveredIds.add(contact.publicKeyHex);
}
}
if (recoveredIds.isNotEmpty) {
_lowBatteryNotifiedNodeIds.removeAll(recoveredIds);
}
if (connectionProvider.deviceInfo.isConnected && deviceBattery != null) {
await _notifyLowBatteryIfNeeded(
nodeId: 'device',
nodeName:
connectionProvider.deviceInfo.selfName?.trim().isNotEmpty == true
? connectionProvider.deviceInfo.selfName!.trim()
: (connectionProvider.deviceInfo.displayName ?? 'Connected device'),
batteryPercent: deviceBattery,
isCurrentDevice: true,
);
}
for (final contact in contactsProvider.contacts) {
if (contact.isChannel) continue;
final battery = contact.displayBattery;
if (battery == null) continue;
await _notifyLowBatteryIfNeeded(
nodeId: contact.publicKeyHex,
nodeName: contact.displayName,
batteryPercent: battery,
isCurrentDevice: false,
);
}
}
Future<void> _notifyLowBatteryIfNeeded({
required String nodeId,
required String nodeName,
required double batteryPercent,
required bool isCurrentDevice,
}) async {
if (batteryPercent >= _lowBatteryThresholdPercent) {
return;
}
if (_lowBatteryNotifiedNodeIds.contains(nodeId)) {
return;
}
final shown = await _notificationService.showLowBatteryNotification(
nodeId: nodeId,
nodeName: nodeName,
batteryPercent: batteryPercent,
isCurrentDevice: isCurrentDevice,
);
if (shown) {
_lowBatteryNotifiedNodeIds.add(nodeId);
}
}
String _packetLogSignature(BlePacketLog log) { String _packetLogSignature(BlePacketLog log) {
final prefix = log.rawData.length <= 12 final prefix = log.rawData.length <= 12
? log.rawData ? log.rawData
@@ -251,7 +336,8 @@ class AppProvider with ChangeNotifier {
Future<void> _syncDrawingsOnStartup() async { Future<void> _syncDrawingsOnStartup() async {
// Wait for both MessagesProvider and DrawingProvider to finish initializing // Wait for both MessagesProvider and DrawingProvider to finish initializing
int attempts = 0; int attempts = 0;
while ((!messagesProvider.isInitialized || !drawingProvider.isInitialized) && while ((!messagesProvider.isInitialized ||
!drawingProvider.isInitialized) &&
attempts < 40) { attempts < 40) {
await Future.delayed(const Duration(milliseconds: 50)); await Future.delayed(const Duration(milliseconds: 50));
attempts++; attempts++;
@@ -649,30 +735,6 @@ class AppProvider with ChangeNotifier {
} }
} }
/// Load auto-add discovered contacts setting from shared preferences.
Future<void> _loadAutoAddDiscoveredContacts() async {
try {
final prefs = await SharedPreferences.getInstance();
_autoAddDiscoveredContacts =
prefs.getBool('auto_add_discovered_contacts') ?? false;
notifyListeners();
} catch (e) {
debugPrint('Error loading auto-add discovered contacts setting: $e');
}
}
/// Toggle auto-add discovered contacts on/off.
Future<void> toggleAutoAddDiscoveredContacts(bool enabled) async {
try {
_autoAddDiscoveredContacts = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('auto_add_discovered_contacts', enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving auto-add discovered contacts setting: $e');
}
}
Future<void> _loadMessagingRouteSettings() async { Future<void> _loadMessagingRouteSettings() async {
try { try {
_autoRouteRotationEnabled = _autoRouteRotationEnabled =
@@ -1432,20 +1494,22 @@ class AppProvider with ChangeNotifier {
} }
}); });
} else { } else {
if (_autoAddDiscoveredContacts) { final isNewPendingAdvert = contactsProvider.addPendingAdvert(
debugPrint(' Unknown contact - auto-add enabled, fetching details');
Future.delayed(const Duration(milliseconds: 100), () {
if (connectionProvider.deviceInfo.isConnected) {
connectionProvider.getContact(publicKey);
}
});
} else {
contactsProvider.addPendingAdvert(
publicKey, publicKey,
devicePublicKey: connectionProvider.deviceInfo.publicKey, devicePublicKey: connectionProvider.deviceInfo.publicKey,
); );
debugPrint( debugPrint(
' Unknown contact - added to pending adverts list and waiting for details', ' Unknown contact - added to pending adverts list and waiting for manual resolution',
);
if (isNewPendingAdvert) {
final keyHex = publicKey
.take(6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
unawaited(
_notificationService.showContactDiscoveredNotification(
contactKey: keyHex,
),
); );
} }
} }
@@ -3074,6 +3138,7 @@ class AppProvider with ChangeNotifier {
_imageMissingRetryAttempts.clear(); _imageMissingRetryAttempts.clear();
_voiceSessionSenderKey6.clear(); _voiceSessionSenderKey6.clear();
_imageSessionSenderKey6.clear(); _imageSessionSenderKey6.clear();
_lowBatteryNotifiedNodeIds.clear();
notifyListeners(); notifyListeners();
} }
@@ -3094,6 +3159,7 @@ class AppProvider with ChangeNotifier {
@override @override
void dispose() { void dispose() {
_packetCaptureFlushTimer?.cancel(); _packetCaptureFlushTimer?.cancel();
_lowBatteryCheckTimer?.cancel();
unawaited(_flushPacketCaptureLogs()); unawaited(_flushPacketCaptureLogs());
// Remove connection state listener // Remove connection state listener
connectionProvider.removeListener(_handleConnectionStateChange); connectionProvider.removeListener(_handleConnectionStateChange);

View File

@@ -2255,7 +2255,7 @@ class ConnectionProvider with ChangeNotifier {
if (!_activeService.isConnected) { if (!_activeService.isConnected) {
_error = 'Not connected to device'; _error = 'Not connected to device';
notifyListeners(); notifyListeners();
return; throw Exception(_error);
} }
try { try {
@@ -2263,6 +2263,7 @@ class ConnectionProvider with ChangeNotifier {
} catch (e) { } catch (e) {
_error = 'Failed to remove contact: $e'; _error = 'Failed to remove contact: $e';
notifyListeners(); notifyListeners();
rethrow;
} }
} }

View File

@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
@@ -66,6 +67,8 @@ class ContactsProvider with ChangeNotifier {
bool _isInitialized = false; bool _isInitialized = false;
bool _isPersisting = false; bool _isPersisting = false;
bool _persistRequested = false; bool _persistRequested = false;
bool _isPersistingPendingAdverts = false;
bool _persistPendingAdvertsRequested = false;
// Add default public channel on initialization // Add default public channel on initialization
ContactsProvider() { ContactsProvider() {
@@ -85,6 +88,7 @@ class ContactsProvider with ChangeNotifier {
); );
final storedContacts = await _storageService.loadContacts(); final storedContacts = await _storageService.loadContacts();
final storedGroups = await _storageService.loadContactGroups(); final storedGroups = await _storageService.loadContactGroups();
final storedPendingAdverts = await _storageService.loadPendingAdverts();
// Add stored contacts (excluding any with all-zeros public key) // Add stored contacts (excluding any with all-zeros public key)
const publicChannelKey = const publicChannelKey =
@@ -101,6 +105,7 @@ class ContactsProvider with ChangeNotifier {
_savedContactGroups _savedContactGroups
..clear() ..clear()
..addAll(storedGroups); ..addAll(storedGroups);
_restorePendingAdverts(storedPendingAdverts);
debugPrint( debugPrint(
'✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups', '✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
); );
@@ -133,6 +138,7 @@ class ContactsProvider with ChangeNotifier {
excludePublicKey: devicePublicKey, excludePublicKey: devicePublicKey,
); );
final storedGroups = await _storageService.loadContactGroups(); final storedGroups = await _storageService.loadContactGroups();
final storedPendingAdverts = await _storageService.loadPendingAdverts();
// Add stored contacts (excluding any with all-zeros public key) // Add stored contacts (excluding any with all-zeros public key)
const publicChannelKey = const publicChannelKey =
@@ -149,6 +155,10 @@ class ContactsProvider with ChangeNotifier {
_savedContactGroups _savedContactGroups
..clear() ..clear()
..addAll(storedGroups); ..addAll(storedGroups);
_restorePendingAdverts(
storedPendingAdverts,
devicePublicKey: devicePublicKey,
);
debugPrint( debugPrint(
'✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups', '✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
); );
@@ -212,14 +222,7 @@ class ContactsProvider with ChangeNotifier {
try { try {
while (_persistRequested) { while (_persistRequested) {
_persistRequested = false; _persistRequested = false;
// Don't persist the public channel pseudo-contact (all zeros key) await _storageService.saveContacts(_contactsForStorage());
const publicChannelKey =
'0000000000000000000000000000000000000000000000000000000000000000';
final contactsToSave = _contacts.entries
.where((entry) => entry.key != publicChannelKey)
.map((entry) => entry.value)
.toList();
await _storageService.saveContacts(contactsToSave);
} }
} catch (e) { } catch (e) {
debugPrint('❌ [ContactsProvider] Error persisting contacts: $e'); debugPrint('❌ [ContactsProvider] Error persisting contacts: $e');
@@ -228,6 +231,24 @@ class ContactsProvider with ChangeNotifier {
} }
} }
Future<void> _persistPendingAdverts() async {
_persistPendingAdvertsRequested = true;
if (_isPersistingPendingAdverts) return;
_isPersistingPendingAdverts = true;
try {
while (_persistPendingAdvertsRequested) {
_persistPendingAdvertsRequested = false;
await _storageService.savePendingAdverts(
_pendingAdverts.values.map(_pendingAdvertToJson).toList(),
);
}
} catch (e) {
debugPrint('❌ [ContactsProvider] Error persisting pending adverts: $e');
} finally {
_isPersistingPendingAdverts = false;
}
}
List<Contact> get contacts => _contacts.values.toList(); List<Contact> get contacts => _contacts.values.toList();
List<SavedContactGroup> get savedContactGroups => List<SavedContactGroup> get savedContactGroups =>
List<SavedContactGroup>.from(_savedContactGroups) List<SavedContactGroup>.from(_savedContactGroups)
@@ -450,6 +471,7 @@ class ContactsProvider with ChangeNotifier {
' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}', ' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}',
); );
_persistContacts(); _persistContacts();
_persistPendingAdverts();
notifyListeners(); notifyListeners();
debugPrint(' 🔔 notifyListeners() called'); debugPrint(' 🔔 notifyListeners() called');
} }
@@ -481,6 +503,7 @@ class ContactsProvider with ChangeNotifier {
); );
} }
_persistContacts(); _persistContacts();
_persistPendingAdverts();
notifyListeners(); notifyListeners();
} }
@@ -1073,9 +1096,9 @@ class ContactsProvider with ChangeNotifier {
/// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80). /// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80).
/// Excludes self key and existing contacts. /// Excludes self key and existing contacts.
void addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) { bool addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) {
if (devicePublicKey != null && publicKey.matches(devicePublicKey)) { if (devicePublicKey != null && publicKey.matches(devicePublicKey)) {
return; return false;
} }
final keyHex = publicKey final keyHex = publicKey
@@ -1083,20 +1106,26 @@ class ContactsProvider with ChangeNotifier {
.join(''); .join('');
if (_contacts.containsKey(keyHex)) { if (_contacts.containsKey(keyHex)) {
_pendingAdverts.remove(keyHex); _pendingAdverts.remove(keyHex);
return; _persistPendingAdverts();
return false;
} }
final existing = _pendingAdverts[keyHex]; final existing = _pendingAdverts[keyHex];
final now = DateTime.now(); final now = DateTime.now();
if (existing != null) { if (existing != null) {
_pendingAdverts[keyHex] = existing.copyWith(receivedAt: now); _pendingAdverts[keyHex] = existing.copyWith(receivedAt: now);
_persistPendingAdverts();
notifyListeners();
return false;
} else { } else {
_pendingAdverts[keyHex] = PendingAdvert( _pendingAdverts[keyHex] = PendingAdvert(
publicKey: Uint8List.fromList(publicKey), publicKey: Uint8List.fromList(publicKey),
receivedAt: now, receivedAt: now,
); );
} _persistPendingAdverts();
notifyListeners(); notifyListeners();
return true;
}
} }
/// Find contact by name /// Find contact by name
@@ -1155,6 +1184,7 @@ class ContactsProvider with ChangeNotifier {
_pendingAdverts.clear(); _pendingAdverts.clear();
_ensurePublicChannelExists(); _ensurePublicChannelExists();
_persistContacts(); _persistContacts();
_persistPendingAdverts();
notifyListeners(); notifyListeners();
} }
@@ -1177,6 +1207,7 @@ class ContactsProvider with ChangeNotifier {
_contacts.remove(publicKeyHex); _contacts.remove(publicKeyHex);
_pendingAdverts.remove(publicKeyHex); _pendingAdverts.remove(publicKeyHex);
_persistContacts(); _persistContacts();
_persistPendingAdverts();
notifyListeners(); notifyListeners();
} }
@@ -1185,6 +1216,74 @@ class ContactsProvider with ChangeNotifier {
return await _storageService.getStorageStats(); return await _storageService.getStorageStats();
} }
Future<void> persistNow() async {
await _storageService.saveContacts(_contactsForStorage());
}
List<Contact> _contactsForStorage() {
// Don't persist the public channel pseudo-contact (all zeros key)
const publicChannelKey =
'0000000000000000000000000000000000000000000000000000000000000000';
return _contacts.entries
.where((entry) => entry.key != publicChannelKey)
.map((entry) => entry.value)
.toList();
}
void _restorePendingAdverts(
List<Map<String, dynamic>> storedPendingAdverts, {
Uint8List? devicePublicKey,
}) {
_pendingAdverts.clear();
for (final json in storedPendingAdverts) {
final advert = _pendingAdvertFromJson(json);
if (advert == null) continue;
if (devicePublicKey != null &&
advert.publicKey.matches(devicePublicKey)) {
continue;
}
if (_contacts.containsKey(advert.publicKeyHex)) {
continue;
}
_pendingAdverts[advert.publicKeyHex] = advert;
}
}
Map<String, dynamic> _pendingAdvertToJson(PendingAdvert advert) {
return {
'publicKey': base64Encode(advert.publicKey),
'receivedAtMillis': advert.receivedAt.millisecondsSinceEpoch,
'signedEncodedPathLen': advert.signedEncodedPathLen,
'paddedPathBytes': advert.paddedPathBytes == null
? null
: base64Encode(advert.paddedPathBytes!),
};
}
PendingAdvert? _pendingAdvertFromJson(Map<String, dynamic> json) {
try {
return PendingAdvert(
publicKey: Uint8List.fromList(
base64Decode(json['publicKey'] as String),
),
receivedAt: DateTime.fromMillisecondsSinceEpoch(
json['receivedAtMillis'] as int,
),
signedEncodedPathLen: json['signedEncodedPathLen'] as int?,
paddedPathBytes: json['paddedPathBytes'] == null
? null
: Uint8List.fromList(
base64Decode(json['paddedPathBytes'] as String),
),
);
} catch (e) {
debugPrint(
'❌ [ContactsProvider] Error parsing pending advert from JSON: $e',
);
return null;
}
}
/// Get contact count by type /// Get contact count by type
Map<String, int> get contactCounts { Map<String, int> get contactCounts {
return { return {

View File

@@ -18,6 +18,8 @@ import '../utils/image_message_parser.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import 'helpers/message_retry_manager.dart'; import 'helpers/message_retry_manager.dart';
typedef DisplayMessageEntry = ({Message message, int occurrenceCount});
/// Messages Provider - manages message history and SAR markers /// Messages Provider - manages message history and SAR markers
class MessagesProvider with ChangeNotifier { class MessagesProvider with ChangeNotifier {
static const Duration _channelEchoWarningDelay = Duration(seconds: 12); static const Duration _channelEchoWarningDelay = Duration(seconds: 12);
@@ -591,8 +593,12 @@ class MessagesProvider with ChangeNotifier {
if (contactLocationSnapshot != null) { if (contactLocationSnapshot != null) {
_messageContactLocations[existingId] = contactLocationSnapshot; _messageContactLocations[existingId] = contactLocationSnapshot;
} }
_messageReceptionDetails[existingId] =
MessageReceptionDetails.mergeDuplicate(
existing: _messageReceptionDetails[existingId],
incoming: receptionDetailsSnapshot,
);
if (receptionDetailsSnapshot != null) { if (receptionDetailsSnapshot != null) {
_messageReceptionDetails[existingId] = receptionDetailsSnapshot;
final existingMessage = _messages[duplicateIndex]; final existingMessage = _messages[duplicateIndex];
final duplicatePathBytes = receptionDetailsSnapshot.pathBytes; final duplicatePathBytes = receptionDetailsSnapshot.pathBytes;
if (existingMessage.pathBytes == null && if (existingMessage.pathBytes == null &&
@@ -775,7 +781,13 @@ class MessagesProvider with ChangeNotifier {
enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage); enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage);
// Check for duplicates // Check for duplicates
if (_findDuplicateMessageIndex(enhancedMessage) != -1) { final duplicateIndex = _findDuplicateMessageIndex(enhancedMessage);
if (duplicateIndex != -1) {
final existingId = _messages[duplicateIndex].id;
_messageReceptionDetails[existingId] =
MessageReceptionDetails.mergeDuplicate(
existing: _messageReceptionDetails[existingId],
);
duplicateCount++; duplicateCount++;
continue; // Skip duplicate continue; // Skip duplicate
} }
@@ -1026,7 +1038,9 @@ class MessagesProvider with ChangeNotifier {
/// overlapping serialization and redundant SharedPreferences writes. /// overlapping serialization and redundant SharedPreferences writes.
Future<void> _persistMessages() async { Future<void> _persistMessages() async {
_persistRequested = true; _persistRequested = true;
if (_isPersisting) return; // A write is in flight; it will pick up our changes. if (_isPersisting) {
return; // A write is in flight; it will pick up our changes.
}
_isPersisting = true; _isPersisting = true;
try { try {
while (_persistRequested) { while (_persistRequested) {
@@ -1121,6 +1135,35 @@ class MessagesProvider with ChangeNotifier {
return sorted.take(count).toList(); return sorted.take(count).toList();
} }
List<DisplayMessageEntry> buildDisplayMessages(Iterable<Message> messages) {
final entries = <DisplayMessageEntry>[];
for (final message in messages) {
final occurrenceCount = _messageOccurrenceCount(message);
final existingIndex = entries.indexWhere(
(entry) =>
entry.message.text == message.text &&
_matchesDuplicateScope(entry.message, message),
);
if (existingIndex == -1) {
entries.add((message: message, occurrenceCount: occurrenceCount));
continue;
}
final existingEntry = entries[existingIndex];
entries[existingIndex] = (
message: existingEntry.message,
occurrenceCount: existingEntry.occurrenceCount + occurrenceCount,
);
}
return entries;
}
int _messageOccurrenceCount(Message message) =>
_messageReceptionDetails[message.id]?.receivedCopies ?? 1;
/// Get messages from last N hours /// Get messages from last N hours
List<Message> getMessagesSince(Duration duration) { List<Message> getMessagesSince(Duration duration) {
final cutoff = DateTime.now().subtract(duration); final cutoff = DateTime.now().subtract(duration);

View File

@@ -34,8 +34,6 @@ class ContactsTab extends StatefulWidget {
class _ContactsTabState extends State<ContactsTab> { class _ContactsTabState extends State<ContactsTab> {
Position? _currentPosition; Position? _currentPosition;
final Set<String> _resolvingAdvertKeys = <String>{};
bool _isResolvingPendingBatch = false;
final Map<ContactSection, String> _sectionFilters = { final Map<ContactSection, String> _sectionFilters = {
ContactSection.teamMembers: '', ContactSection.teamMembers: '',
ContactSection.repeaters: '', ContactSection.repeaters: '',
@@ -99,57 +97,6 @@ class _ContactsTabState extends State<ContactsTab> {
await _getCurrentLocation(); await _getCurrentLocation();
} }
Future<void> _handleResolveAdvert(PendingAdvert advert) async {
final keyHex = advert.publicKeyHex;
if (_resolvingAdvertKeys.contains(keyHex)) return;
setState(() {
_resolvingAdvertKeys.add(keyHex);
});
try {
await context.read<ConnectionProvider>().getContact(advert.publicKey);
} finally {
if (mounted) {
setState(() {
_resolvingAdvertKeys.remove(keyHex);
});
}
}
}
void _schedulePendingAdvertResolution(
List<PendingAdvert> pendingAdverts,
ConnectionProvider connectionProvider,
) {
if (_isResolvingPendingBatch ||
!connectionProvider.deviceInfo.isConnected ||
pendingAdverts.isEmpty) {
return;
}
final advertsToResolve = pendingAdverts
.where((advert) => !_resolvingAdvertKeys.contains(advert.publicKeyHex))
.toList();
if (advertsToResolve.isEmpty) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!mounted || _isResolvingPendingBatch) return;
_isResolvingPendingBatch = true;
try {
for (final advert in advertsToResolve) {
if (!mounted) break;
await _handleResolveAdvert(advert);
}
} finally {
_isResolvingPendingBatch = false;
}
});
}
/// Calculate distance between two points in meters /// Calculate distance between two points in meters
double _calculateDistanceInMeters( double _calculateDistanceInMeters(
double lat1, double lat1,
@@ -181,15 +128,6 @@ class _ContactsTabState extends State<ContactsTab> {
} }
} }
String _formatRelativeTime(BuildContext context, DateTime when) {
final l10n = AppLocalizations.of(context)!;
final diff = DateTime.now().difference(when);
if (diff.inMinutes < 1) return l10n.justNow;
if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours);
return l10n.daysAgo(diff.inDays);
}
List<Contact> _filterContactsForSection( List<Contact> _filterContactsForSection(
List<Contact> contacts, List<Contact> contacts,
ContactSection section, ContactSection section,
@@ -593,7 +531,6 @@ class _ContactsTabState extends State<ContactsTab> {
body: Consumer<ContactsProvider>( body: Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) { builder: (context, contactsProvider, child) {
final messagesProvider = context.watch<MessagesProvider>(); final messagesProvider = context.watch<MessagesProvider>();
final connectionProvider = context.watch<ConnectionProvider>();
final allChatContacts = _sortContacts( final allChatContacts = _sortContacts(
contactsProvider.chatContacts, contactsProvider.chatContacts,
ContactSection.teamMembers, ContactSection.teamMembers,
@@ -673,17 +610,12 @@ class _ContactsTabState extends State<ContactsTab> {
final showRepeatersSection = allRepeaters.isNotEmpty; final showRepeatersSection = allRepeaters.isNotEmpty;
final showRoomsSection = allRooms.isNotEmpty; final showRoomsSection = allRooms.isNotEmpty;
final showChannelsSection = allChannels.isNotEmpty; final showChannelsSection = allChannels.isNotEmpty;
final pendingAdverts = contactsProvider.pendingAdverts;
_schedulePendingAdvertResolution(pendingAdverts, connectionProvider);
// Check if there are any displayable contacts // Check if there are any displayable contacts
final hasDisplayableContacts = final hasDisplayableContacts =
allChatContacts.isNotEmpty || allChatContacts.isNotEmpty ||
allRepeaters.isNotEmpty || allRepeaters.isNotEmpty ||
allRooms.isNotEmpty || allRooms.isNotEmpty ||
allChannels.isNotEmpty || allChannels.isNotEmpty;
pendingAdverts.isNotEmpty;
if (!hasDisplayableContacts) { if (!hasDisplayableContacts) {
return Center( return Center(
@@ -838,27 +770,6 @@ class _ContactsTabState extends State<ContactsTab> {
const Divider(height: 32), const Divider(height: 32),
], ],
// Pending adverts are kept below resolved sections while we load details.
if (pendingAdverts.isNotEmpty) ...[
_SectionHeader(
title: l10n.pending,
count: pendingAdverts.length,
icon: Icons.person_search,
),
...pendingAdverts.map(
(advert) => _PendingAdvertTile(
advert: advert,
subtitle:
'${l10n.publicKey}: ${advert.shortDisplayKey}\n${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}',
isResolving: _resolvingAdvertKeys.contains(
advert.publicKeyHex,
),
onResolve: () => _handleResolveAdvert(advert),
),
),
const Divider(height: 32),
],
// Channels (visible in both simple and advanced mode) // Channels (visible in both simple and advanced mode)
if (showChannelsSection) ...[ if (showChannelsSection) ...[
_SectionHeader( _SectionHeader(
@@ -1263,46 +1174,6 @@ enum ContactSortMode { lastSeen, distance }
enum ContactSection { teamMembers, repeaters, rooms, channels } enum ContactSection { teamMembers, repeaters, rooms, channels }
class _PendingAdvertTile extends StatelessWidget {
final PendingAdvert advert;
final String subtitle;
final bool isResolving;
final VoidCallback onResolve;
const _PendingAdvertTile({
required this.advert,
required this.subtitle,
required this.isResolving,
required this.onResolve,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: const CircleAvatar(child: Icon(Icons.campaign_outlined)),
title: Text(
advert.shortDisplayKey,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(subtitle),
trailing: isResolving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: IconButton(
icon: const Icon(Icons.person_add_alt_1),
tooltip: 'Resolve contact',
onPressed: onResolve,
),
),
);
}
}
class _RenderedSavedGroup { class _RenderedSavedGroup {
final SavedContactGroup group; final SavedContactGroup group;
final List<Contact> contacts; final List<Contact> contacts;

View File

@@ -1,8 +1,14 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/device_info.dart'; import '../models/device_info.dart';
import '../models/channel.dart';
import '../models/contact.dart';
import '../providers/channels_provider.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart';
import '../services/validation_service.dart'; import '../services/validation_service.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@@ -14,6 +20,10 @@ class DeviceConfigScreen extends StatefulWidget {
} }
class _DeviceConfigScreenState extends State<DeviceConfigScreen> { class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
static const int _bulkDeleteBatchSize = 8;
static const Duration _bulkDeleteInterItemDelay = Duration(milliseconds: 120);
static const Duration _bulkDeleteBatchDelay = Duration(milliseconds: 700);
static const Duration _bulkDeleteFinalSyncDelay = Duration(milliseconds: 900);
static const List<_RadioPreset> _radioPresets = [ static const List<_RadioPreset> _radioPresets = [
_RadioPreset( _RadioPreset(
id: 'australia', id: 'australia',
@@ -169,9 +179,12 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
bool _telemetryEnabled = false; bool _telemetryEnabled = false;
bool _repeatEnabled = false; bool _repeatEnabled = false;
bool _autoAddDiscoveredContactsEnabled = true;
bool _showCustomRadioSettings = false; bool _showCustomRadioSettings = false;
bool _isSavingPublicInfo = false; bool _isSavingPublicInfo = false;
bool _isSavingRadioSettings = false; bool _isSavingRadioSettings = false;
bool _isClearingContacts = false;
bool _isClearingChannels = false;
bool _publicInfoSaved = false; bool _publicInfoSaved = false;
bool _radioSettingsSaved = false; bool _radioSettingsSaved = false;
String? _publicInfoError; String? _publicInfoError;
@@ -252,6 +265,8 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
// Initialize repeat mode from device info (firmware v9+) // Initialize repeat mode from device info (firmware v9+)
_repeatEnabled = deviceInfo.clientRepeat ?? false; _repeatEnabled = deviceInfo.clientRepeat ?? false;
_autoAddDiscoveredContactsEnabled =
!(deviceInfo.manualAddContacts ?? false);
// Fetch allowed repeat frequencies on open if device supports repeat mode // Fetch allowed repeat frequencies on open if device supports repeat mode
if (deviceInfo.clientRepeat != null && if (deviceInfo.clientRepeat != null &&
@@ -377,7 +392,6 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
Future<void> _savePublicInfo() async { Future<void> _savePublicInfo() async {
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final deviceInfo = connectionProvider.deviceInfo;
final validator = ValidationService(); final validator = ValidationService();
setState(() { setState(() {
@@ -387,6 +401,8 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
}); });
try { try {
final manualAddContacts = _autoAddDiscoveredContactsEnabled ? 0 : 1;
// Save name // Save name
if (_nameController.text.isNotEmpty) { if (_nameController.text.isNotEmpty) {
await connectionProvider.setAdvertName(_nameController.text); await connectionProvider.setAdvertName(_nameController.text);
@@ -425,7 +441,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
// Set telemetry modes to "Allow All" (mode 2 for both base and location) // Set telemetry modes to "Allow All" (mode 2 for both base and location)
final telemetryModes = 0x0A; // binary: 00001010 (base=2, location=2) final telemetryModes = 0x0A; // binary: 00001010 (base=2, location=2)
await connectionProvider.setOtherParams( await connectionProvider.setOtherParams(
manualAddContacts: deviceInfo.manualAddContacts == true ? 1 : 0, manualAddContacts: manualAddContacts,
telemetryModes: telemetryModes, telemetryModes: telemetryModes,
advertLocationPolicy: 1, advertLocationPolicy: 1,
); );
@@ -436,7 +452,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
// Set telemetry modes to "Deny" (mode 0) // Set telemetry modes to "Deny" (mode 0)
final telemetryModes = 0x00; final telemetryModes = 0x00;
await connectionProvider.setOtherParams( await connectionProvider.setOtherParams(
manualAddContacts: deviceInfo.manualAddContacts == true ? 1 : 0, manualAddContacts: manualAddContacts,
telemetryModes: telemetryModes, telemetryModes: telemetryModes,
advertLocationPolicy: 0, advertLocationPolicy: 0,
); );
@@ -687,6 +703,205 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
} }
} }
Future<void> _confirmClearAllContacts() async {
final contacts = context
.read<ContactsProvider>()
.contacts
.where((contact) => !contact.isChannel)
.toList();
if (contacts.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No device contacts to clear.')),
);
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Clear all contacts'),
content: Text(
'This will remove ${contacts.length} contact${contacts.length == 1 ? '' : 's'} from the connected device. Channels and radio settings will not be changed.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(AppLocalizations.of(context)!.cancel),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Clear contacts'),
),
],
),
);
if (confirmed != true || !mounted) return;
final messenger = ScaffoldMessenger.of(context);
final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>();
connectionProvider.clearError();
setState(() {
_isClearingContacts = true;
});
try {
var processed = 0;
for (final contact in List<Contact>.from(contacts)) {
await contactsProvider.removeContact(
contact.publicKeyHex,
onRemoveFromDevice: connectionProvider.removeContact,
);
processed++;
await Future.delayed(_bulkDeleteInterItemDelay);
if (processed % _bulkDeleteBatchSize == 0) {
await Future.delayed(_bulkDeleteBatchDelay);
}
}
// Flush the updated local contact set to storage immediately.
await contactsProvider.persistNow();
await Future.delayed(_bulkDeleteFinalSyncDelay);
await connectionProvider.getContacts();
if (connectionProvider.error != null) {
throw Exception(connectionProvider.error!);
}
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text(
'Cleared ${contacts.length} contact${contacts.length == 1 ? '' : 's'} from the device.',
),
),
);
} catch (e) {
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text('Failed to clear contacts: $e'),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
} finally {
if (mounted) {
setState(() {
_isClearingContacts = false;
});
}
}
}
Future<void> _confirmClearAllChannels() async {
final channels = context
.read<ChannelsProvider>()
.channels
.where((channel) => !channel.isPublicChannel && channel.name.isNotEmpty)
.toList();
if (channels.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No custom channels to clear.')),
);
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Clear all channels'),
content: Text(
'This will remove ${channels.length} custom channel${channels.length == 1 ? '' : 's'} from the connected device. Contacts and radio settings will not be changed.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(AppLocalizations.of(context)!.cancel),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Clear channels'),
),
],
),
);
if (confirmed != true || !mounted) return;
final messenger = ScaffoldMessenger.of(context);
final connectionProvider = context.read<ConnectionProvider>();
final channelsProvider = context.read<ChannelsProvider>();
final contactsProvider = context.read<ContactsProvider>();
connectionProvider.clearError();
setState(() {
_isClearingChannels = true;
});
try {
var processed = 0;
for (final channel in List<Channel>.from(channels)) {
await connectionProvider.deleteChannel(channel.index);
processed++;
await Future.delayed(_bulkDeleteInterItemDelay);
if (processed % _bulkDeleteBatchSize == 0) {
await Future.delayed(_bulkDeleteBatchDelay);
}
}
// Force local channel/contact cache cleanup before the device resync.
for (final channel in channels) {
channelsProvider.removeChannel(channel.index);
final publicKeyBytes = Uint8List(32);
publicKeyBytes[0] = 0xFF;
publicKeyBytes[1] = channel.index;
final publicKeyHex = publicKeyBytes
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
await contactsProvider.removeContact(publicKeyHex);
}
await contactsProvider.persistNow();
await Future.delayed(_bulkDeleteFinalSyncDelay);
await connectionProvider.syncChannels();
if (connectionProvider.error != null) {
throw Exception(connectionProvider.error!);
}
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text(
'Cleared ${channels.length} custom channel${channels.length == 1 ? '' : 's'} from the device.',
),
),
);
} catch (e) {
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text('Failed to clear channels: $e'),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
} finally {
if (mounted) {
setState(() {
_isClearingChannels = false;
});
}
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final deviceInfo = context.watch<ConnectionProvider>().deviceInfo; final deviceInfo = context.watch<ConnectionProvider>().deviceInfo;
@@ -788,6 +1003,28 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_SettingHighlightCard(
icon: _autoAddDiscoveredContactsEnabled
? Icons.person_add_alt_1
: Icons.person_add_disabled,
title: 'Auto-add discovered contacts',
description:
'Control whether the device automatically stores newly discovered contacts.',
accentColor: _autoAddDiscoveredContactsEnabled
? colorScheme.primary
: colorScheme.onSurfaceVariant,
trailing: Switch(
value: _autoAddDiscoveredContactsEnabled,
onChanged: (value) {
setState(() {
_autoAddDiscoveredContactsEnabled = value;
_publicInfoSaved = false;
_publicInfoError = null;
});
},
),
),
const SizedBox(height: 18),
_SettingHighlightCard( _SettingHighlightCard(
icon: _telemetryEnabled icon: _telemetryEnabled
? Icons.travel_explore ? Icons.travel_explore
@@ -1230,10 +1467,60 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _isClearingContacts || _isClearingChannels
? null
: _confirmClearAllContacts,
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.error,
side: BorderSide(color: colorScheme.error),
minimumSize: const Size.fromHeight(52),
),
icon: _isClearingContacts
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.people_alt_outlined),
label: const Text('Clear all contacts'),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _isClearingContacts || _isClearingChannels
? null
: _confirmClearAllChannels,
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.error,
side: BorderSide(color: colorScheme.error),
minimumSize: const Size.fromHeight(52),
),
icon: _isClearingChannels
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.forum_outlined),
label: const Text('Clear all channels'),
),
),
const SizedBox(height: 12),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton.icon( child: FilledButton.icon(
onPressed: _confirmFactoryReset, onPressed: _isClearingContacts || _isClearingChannels
? null
: _confirmFactoryReset,
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: colorScheme.error, backgroundColor: colorScheme.error,
foregroundColor: colorScheme.onError, foregroundColor: colorScheme.onError,

View File

@@ -0,0 +1,231 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../models/contact.dart';
import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart';
import '../services/mesh_map_nodes_service.dart';
class DiscoveryScreen extends StatefulWidget {
const DiscoveryScreen({super.key});
@override
State<DiscoveryScreen> createState() => _DiscoveryScreenState();
}
class _DiscoveryScreenState extends State<DiscoveryScreen> {
final Set<String> _resolvingAdvertKeys = <String>{};
bool _isResolvingAll = false;
late final Future<List<MeshMapNode>> _cachedNodesFuture;
@override
void initState() {
super.initState();
_cachedNodesFuture = MeshMapNodesService.loadCachedNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl,
);
}
Future<void> _resolveAdvert(PendingAdvert advert) async {
final keyHex = advert.publicKeyHex;
if (_resolvingAdvertKeys.contains(keyHex)) return;
setState(() {
_resolvingAdvertKeys.add(keyHex);
});
try {
await context.read<ConnectionProvider>().getContact(advert.publicKey);
} finally {
if (mounted) {
setState(() {
_resolvingAdvertKeys.remove(keyHex);
});
}
}
}
Future<void> _resolveAll(List<PendingAdvert> adverts) async {
if (_isResolvingAll || adverts.isEmpty) return;
setState(() {
_isResolvingAll = true;
});
try {
for (final advert in adverts) {
if (!mounted) break;
await _resolveAdvert(advert);
}
} finally {
if (mounted) {
setState(() {
_isResolvingAll = false;
});
}
}
}
String _formatRelativeTime(BuildContext context, DateTime when) {
final l10n = AppLocalizations.of(context)!;
final diff = DateTime.now().difference(when);
if (diff.inMinutes < 1) return l10n.justNow;
if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours);
return l10n.daysAgo(diff.inDays);
}
String _displayNameForAdvert(
PendingAdvert advert,
ContactsProvider contactsProvider,
List<MeshMapNode> cachedNodes,
) {
Contact? existingMatch;
for (final contact in contactsProvider.contacts) {
if (contact.publicKeyHex == advert.publicKeyHex) {
existingMatch = contact;
break;
}
}
if (existingMatch != null && existingMatch.displayName.trim().isNotEmpty) {
return existingMatch.displayName;
}
MeshMapNode? cachedMatch;
for (final node in cachedNodes) {
if (node.publicKey == advert.publicKeyHex.toLowerCase()) {
cachedMatch = node;
break;
}
}
if (cachedMatch != null && cachedMatch.name.trim().isNotEmpty) {
return cachedMatch.name;
}
return advert.shortDisplayKey;
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: const Text('Discovery')),
body: FutureBuilder<List<MeshMapNode>>(
future: _cachedNodesFuture,
builder: (context, nodesSnapshot) =>
Consumer2<ContactsProvider, ConnectionProvider>(
builder: (context, contactsProvider, connectionProvider, child) {
final pendingAdverts = contactsProvider.pendingAdverts;
final isConnected = connectionProvider.deviceInfo.isConnected;
final cachedNodes = nodesSnapshot.data ?? const <MeshMapNode>[];
if (pendingAdverts.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.person_search_outlined,
size: 64,
color: Theme.of(context).disabledColor,
),
const SizedBox(height: 16),
Text(
'No pending discoveries',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Unknown adverts will appear here until you choose to resolve them.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
);
}
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: ListTile(
leading: const Icon(Icons.person_search),
title: Text(
'Pending discoveries (${pendingAdverts.length})',
),
subtitle: const Text(
'Resolve entries manually so they do not auto-populate contacts.',
),
trailing: FilledButton.icon(
onPressed: isConnected && !_isResolvingAll
? () => _resolveAll(pendingAdverts)
: null,
icon: _isResolvingAll
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.download_for_offline_outlined),
label: const Text('Resolve all'),
),
),
),
const SizedBox(height: 12),
...pendingAdverts.map((advert) {
final isResolving = _resolvingAdvertKeys.contains(
advert.publicKeyHex,
);
final displayName = _displayNameForAdvert(
advert,
contactsProvider,
cachedNodes,
);
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: const CircleAvatar(
child: Icon(Icons.campaign_outlined),
),
title: Text(
displayName,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
'${l10n.publicKey}: ${advert.shortDisplayKey}\n'
'${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}',
),
trailing: isResolving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: IconButton(
icon: const Icon(Icons.person_add_alt_1),
tooltip: 'Resolve contact',
onPressed: isConnected
? () => _resolveAdvert(advert)
: null,
),
),
);
}),
],
);
},
),
),
);
}
}

View File

@@ -12,6 +12,7 @@ import '../providers/contacts_provider.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import 'messages_tab.dart'; import 'messages_tab.dart';
import 'contacts_tab.dart'; import 'contacts_tab.dart';
import 'discovery_screen.dart';
import 'sensors_tab.dart'; import 'sensors_tab.dart';
import 'map_tab.dart'; import 'map_tab.dart';
import 'repeaters_map_screen.dart'; import 'repeaters_map_screen.dart';
@@ -712,6 +713,39 @@ class _HomeScreenState extends State<HomeScreen>
), ),
); );
items.add(
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.person_search),
const SizedBox(width: 8),
Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) {
final pendingCount =
contactsProvider.pendingAdverts.length;
return Text(
pendingCount > 0
? 'Discovery ($pendingCount)'
: 'Discovery',
);
},
),
],
),
onTap: () {
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (context) => const DiscoveryScreen(),
),
);
});
},
),
);
items.add( items.add(
PopupMenuItem( PopupMenuItem(
child: Row( child: Row(

View File

@@ -163,9 +163,13 @@ class _MessagesTabState extends State<MessagesTab> {
void _scrollToMessage(String messageId) { void _scrollToMessage(String messageId) {
final messagesProvider = context.read<MessagesProvider>(); final messagesProvider = context.read<MessagesProvider>();
final messages = _getFilteredMessages(messagesProvider); final messages = messagesProvider.buildDisplayMessages(
_getFilteredMessages(messagesProvider),
);
final messageIndex = messages.indexWhere((m) => m.id == messageId); final messageIndex = messages.indexWhere(
(entry) => entry.message.id == messageId,
);
if (messageIndex != -1 && _scrollController.hasClients) { if (messageIndex != -1 && _scrollController.hasClients) {
// Calculate position - accounting for reverse list // Calculate position - accounting for reverse list
@@ -2019,7 +2023,9 @@ class _MessagesTabState extends State<MessagesTab> {
return Consumer<MessagesProvider>( return Consumer<MessagesProvider>(
builder: (context, messagesProvider, child) { builder: (context, messagesProvider, child) {
_syncChannelAutoReadTimer(messagesProvider); _syncChannelAutoReadTimer(messagesProvider);
final messages = _getFilteredMessages(messagesProvider); final messages = messagesProvider.buildDisplayMessages(
_getFilteredMessages(messagesProvider),
);
final bottomInset = MediaQuery.of(context).viewPadding.bottom; final bottomInset = MediaQuery.of(context).viewPadding.bottom;
final composerBottomPadding = bottomInset > 0 ? 2.0 : 10.0; final composerBottomPadding = bottomInset > 0 ? 2.0 : 10.0;

View File

@@ -777,13 +777,7 @@ class _DecodedRouteSection extends StatelessWidget {
), ),
) )
.toList(); .toList();
final originalSender = resolvedPath.isEmpty ? null : resolvedPath.first; return _RouteSection(route: decodedRoute, path: resolvedPath);
return _RouteSection(
route: decodedRoute,
path: resolvedPath,
originalSender: originalSender,
);
}, },
); );
} }
@@ -827,13 +821,8 @@ class _DecodedRouteSection extends StatelessWidget {
class _RouteSection extends StatelessWidget { class _RouteSection extends StatelessWidget {
final DecodedLogRxRoute route; final DecodedLogRxRoute route;
final List<ResolvedNodeHash> path; final List<ResolvedNodeHash> path;
final ResolvedNodeHash? originalSender;
const _RouteSection({ const _RouteSection({required this.route, required this.path});
required this.route,
required this.path,
required this.originalSender,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -879,12 +868,6 @@ class _RouteSection extends StatelessWidget {
value: value:
'${route.hashSize} byte${route.hashSize == 1 ? '' : 's'}', '${route.hashSize} byte${route.hashSize == 1 ? '' : 's'}',
), ),
if (originalSender != null)
_FactCard(
icon: Icons.person_pin_circle,
label: 'Original sender',
value: _nodeLabel(originalSender!),
),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),

View File

@@ -1130,19 +1130,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
_buildSectionHeader('Messaging'), _buildSectionHeader('Messaging'),
_buildSettingsCard([ _buildSettingsCard([
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.person_add_alt_1),
title: const Text('Auto-add discovered contacts'),
subtitle: const Text(
'Automatically fetch and add new contacts when they are discovered',
),
value: appProvider.autoAddDiscoveredContacts,
onChanged: (value) async {
await appProvider.toggleAutoAddDiscoveredContacts(value);
},
),
),
ListTile( ListTile(
leading: const Icon(Icons.alt_route), leading: const Icon(Icons.alt_route),
title: const Text('Route path byte size'), title: const Text('Route path byte size'),

View File

@@ -10,7 +10,9 @@ import 'package:latlong2/latlong.dart';
class ContactStorageService { class ContactStorageService {
static const String _contactsKey = 'stored_contacts'; static const String _contactsKey = 'stored_contacts';
static const String _contactGroupsKey = 'stored_contact_groups'; static const String _contactGroupsKey = 'stored_contact_groups';
static const String _pendingAdvertsKey = 'stored_pending_adverts';
static const int _maxStoredContacts = 500; // Store up to 500 contacts static const int _maxStoredContacts = 500; // Store up to 500 contacts
static const int _maxStoredPendingAdverts = 500;
/// Save contacts to persistent storage /// Save contacts to persistent storage
Future<void> saveContacts(List<Contact> contacts) async { Future<void> saveContacts(List<Contact> contacts) async {
@@ -126,6 +128,47 @@ class ContactStorageService {
} }
} }
Future<void> savePendingAdverts(List<Map<String, dynamic>> adverts) async {
try {
final prefs = await SharedPreferences.getInstance();
final limitedList = adverts.length > _maxStoredPendingAdverts
? adverts.sublist(adverts.length - _maxStoredPendingAdverts)
: adverts;
await prefs.setString(_pendingAdvertsKey, jsonEncode(limitedList));
debugPrint(
'✅ [ContactStorage] Saved ${limitedList.length} pending adverts to storage',
);
} catch (e) {
debugPrint('❌ [ContactStorage] Error saving pending adverts: $e');
}
}
Future<List<Map<String, dynamic>>> loadPendingAdverts() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_pendingAdvertsKey);
if (jsonString == null || jsonString.isEmpty) {
return const [];
}
final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList.whereType<Map<String, dynamic>>().toList();
} catch (e) {
debugPrint('❌ [ContactStorage] Error loading pending adverts: $e');
return const [];
}
}
Future<void> clearPendingAdverts() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_pendingAdvertsKey);
debugPrint('✅ [ContactStorage] Cleared all stored pending adverts');
} catch (e) {
debugPrint('❌ [ContactStorage] Error clearing pending adverts: $e');
}
}
/// Get storage statistics /// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async { Future<Map<String, dynamic>> getStorageStats() async {
try { try {

View File

@@ -31,6 +31,8 @@ class NotificationService {
static const int _sarNotificationId = 1000; static const int _sarNotificationId = 1000;
static const int _messageNotificationId = 2000; static const int _messageNotificationId = 2000;
static const int _updateNotificationId = 3000; static const int _updateNotificationId = 3000;
static const int _batteryNotificationId = 4000;
static const int _discoveryNotificationId = 5000;
// Notification channels // Notification channels
static const String _urgentChannelId = 'sar_urgent'; static const String _urgentChannelId = 'sar_urgent';
@@ -48,6 +50,16 @@ class NotificationService {
static const String _updateChannelDescription = static const String _updateChannelDescription =
'Notifications for available app updates'; 'Notifications for available app updates';
static const String _batteryChannelId = 'battery_alerts';
static const String _batteryChannelName = 'Battery Alerts';
static const String _batteryChannelDescription =
'Notifications when a device or contact battery is low';
static const String _discoveryChannelId = 'discovery_alerts';
static const String _discoveryChannelName = 'Discovery Alerts';
static const String _discoveryChannelDescription =
'Notifications when new contacts are discovered';
bool get messageNotificationsEnabled => _messageNotificationsEnabled; bool get messageNotificationsEnabled => _messageNotificationsEnabled;
bool get sarNotificationsEnabled => _sarNotificationsEnabled; bool get sarNotificationsEnabled => _sarNotificationsEnabled;
bool get updateNotificationsEnabled => _updateNotificationsEnabled; bool get updateNotificationsEnabled => _updateNotificationsEnabled;
@@ -237,9 +249,31 @@ class NotificationService {
showBadge: true, showBadge: true,
); );
const batteryChannel = AndroidNotificationChannel(
_batteryChannelId,
_batteryChannelName,
description: _batteryChannelDescription,
importance: Importance.high,
playSound: true,
enableVibration: true,
showBadge: true,
);
const discoveryChannel = AndroidNotificationChannel(
_discoveryChannelId,
_discoveryChannelName,
description: _discoveryChannelDescription,
importance: Importance.high,
playSound: true,
enableVibration: true,
showBadge: true,
);
await androidPlugin.createNotificationChannel(urgentChannel); await androidPlugin.createNotificationChannel(urgentChannel);
await androidPlugin.createNotificationChannel(messagesChannel); await androidPlugin.createNotificationChannel(messagesChannel);
await androidPlugin.createNotificationChannel(updateChannel); await androidPlugin.createNotificationChannel(updateChannel);
await androidPlugin.createNotificationChannel(batteryChannel);
await androidPlugin.createNotificationChannel(discoveryChannel);
debugPrint('✅ [NotificationService] Created notification channels'); debugPrint('✅ [NotificationService] Created notification channels');
} catch (e) { } catch (e) {
debugPrint('⚠️ [NotificationService] Error creating channels: $e'); debugPrint('⚠️ [NotificationService] Error creating channels: $e');
@@ -707,6 +741,156 @@ class NotificationService {
); );
} }
} }
Future<bool> showLowBatteryNotification({
required String nodeId,
required String nodeName,
required double batteryPercent,
required bool isCurrentDevice,
}) async {
if (!_isInitialized) {
debugPrint(
'⚠️ [NotificationService] Not initialized, skipping notification',
);
return false;
}
if (!_permissionGranted) {
debugPrint(
'⚠️ [NotificationService] Permission not granted, skipping notification',
);
return false;
}
if (!_messageNotificationsEnabled) {
debugPrint(' [NotificationService] Message notifications disabled');
return false;
}
if (_shouldSuppressForegroundNotifications()) {
debugPrint(
' [NotificationService] App in foreground, skipping low battery notification',
);
return false;
}
final roundedPercent = batteryPercent.round().clamp(0, 100);
final title = isCurrentDevice
? 'Device battery low'
: 'Contact battery low';
final body = isCurrentDevice
? '$nodeName battery is at $roundedPercent%.'
: '$nodeName is at $roundedPercent% battery.';
final notificationId =
_batteryNotificationId + ((nodeId.hashCode & 0x7fffffff) % 1000);
try {
final androidDetails = AndroidNotificationDetails(
_batteryChannelId,
_batteryChannelName,
channelDescription: _batteryChannelDescription,
importance: Importance.high,
priority: Priority.high,
ticker: title,
playSound: true,
enableVibration: true,
showWhen: true,
when: DateTime.now().millisecondsSinceEpoch,
styleInformation: BigTextStyleInformation(
body,
contentTitle: title,
summaryText: '$roundedPercent%',
),
);
final darwinDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
sound: 'default',
threadIdentifier: 'battery_alerts',
subtitle: '$roundedPercent%',
);
final notificationDetails = NotificationDetails(
android: androidDetails,
iOS: darwinDetails,
);
await _notificationsPlugin.show(
id: notificationId,
title: title,
body: body,
notificationDetails: notificationDetails,
payload: 'battery:$nodeId',
);
debugPrint(
'✅ [NotificationService] Showed low battery notification for $nodeName ($roundedPercent%)',
);
return true;
} catch (e) {
debugPrint(
'❌ [NotificationService] Error showing low battery notification: $e',
);
return false;
}
}
Future<bool> showContactDiscoveredNotification({
required String contactKey,
}) async {
if (!_isInitialized) return false;
if (!_permissionGranted) return false;
if (!_messageNotificationsEnabled) return false;
if (_shouldSuppressForegroundNotifications()) return false;
final shortKey = contactKey.length > 12
? contactKey.substring(0, 12).toUpperCase()
: contactKey.toUpperCase();
final title = 'New contact discovered';
final body = 'New contact $shortKey is available in Discovery.';
final notificationId =
_discoveryNotificationId + ((contactKey.hashCode & 0x7fffffff) % 1000);
try {
final androidDetails = AndroidNotificationDetails(
_discoveryChannelId,
_discoveryChannelName,
channelDescription: _discoveryChannelDescription,
importance: Importance.high,
priority: Priority.high,
ticker: title,
playSound: true,
enableVibration: true,
showWhen: true,
when: DateTime.now().millisecondsSinceEpoch,
);
final darwinDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
sound: 'default',
threadIdentifier: 'discovery_alerts',
);
await _notificationsPlugin.show(
id: notificationId,
title: title,
body: body,
notificationDetails: NotificationDetails(
android: androidDetails,
iOS: darwinDetails,
),
payload: 'discovery:$contactKey',
);
return true;
} catch (e) {
debugPrint(
'❌ [NotificationService] Error showing discovery notification: $e',
);
return false;
}
}
} }
class _LifecycleObserver with WidgetsBindingObserver { class _LifecycleObserver with WidgetsBindingObserver {

View File

@@ -1,16 +1,17 @@
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map; import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'dart:math' as math;
import '../../models/contact.dart'; import '../../models/contact.dart';
import '../../providers/connection_provider.dart'; import '../../models/path_history.dart';
import '../../providers/app_provider.dart'; import '../../providers/app_provider.dart';
import '../../providers/connection_provider.dart';
import '../../services/contact_route_resolver.dart'; import '../../services/contact_route_resolver.dart';
import '../../services/path_history_service.dart'; import '../../services/path_history_service.dart';
import '../../services/route_hash_preferences.dart'; import '../../services/route_hash_preferences.dart';
import '../../models/path_history.dart';
class ContactRouteDialogResult { class ContactRouteDialogResult {
final ParsedContactRoute? route; final ParsedContactRoute? route;
@@ -53,21 +54,13 @@ class ContactRouteDialog extends StatefulWidget {
required Contact contact, required Contact contact,
required List<Contact> availableContacts, required List<Contact> availableContacts,
}) { }) {
return showModalBottomSheet<ContactRouteDialogResult>( return Navigator.of(context).push<ContactRouteDialogResult>(
context: context, MaterialPageRoute(
isScrollControlled: true, builder: (context) => ContactRouteDialog(
showDragHandle: true,
builder: (context) => SafeArea(
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: ContactRouteDialog(
contact: contact, contact: contact,
availableContacts: availableContacts, availableContacts: availableContacts,
), ),
), ),
),
); );
} }
@@ -77,14 +70,15 @@ class ContactRouteDialog extends StatefulWidget {
class _ContactRouteDialogState extends State<ContactRouteDialog> { class _ContactRouteDialogState extends State<ContactRouteDialog> {
late final TextEditingController _controller; late final TextEditingController _controller;
late final TextEditingController _relaySearchController;
final PathHistoryService _pathHistoryService = PathHistoryService(); final PathHistoryService _pathHistoryService = PathHistoryService();
int _selectedHashSize = RouteHashPreferences.defaultHashSize; int _selectedHashSize = RouteHashPreferences.defaultHashSize;
ParsedContactRoute? _parsedRoute; ParsedContactRoute? _parsedRoute;
String? _errorText; String? _errorText;
bool _showRoutingInfo = false; bool _showRoutingInfo = false;
bool _showManualEditor = false;
List<Contact> _selectedMapHops = const []; List<Contact> _selectedMapHops = const [];
ContactPathHistory? _pathHistory; ContactPathHistory? _pathHistory;
_RouteEntryMode _entryMode = _RouteEntryMode.map;
@override @override
void initState() { void initState() {
@@ -92,10 +86,9 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
_controller = TextEditingController( _controller = TextEditingController(
text: widget.contact.routeCanonicalText, text: widget.contact.routeCanonicalText,
); );
_relaySearchController = TextEditingController();
_controller.addListener(_reparse); _controller.addListener(_reparse);
_entryMode = widget.contact.routeCanonicalText.isNotEmpty _showManualEditor = widget.contact.routeCanonicalText.isNotEmpty;
? _RouteEntryMode.manual
: _RouteEntryMode.map;
_loadHashSizePreference(); _loadHashSizePreference();
_loadPathHistory(); _loadPathHistory();
_reparse(); _reparse();
@@ -103,6 +96,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
@override @override
void dispose() { void dispose() {
_relaySearchController.dispose();
_controller _controller
..removeListener(_reparse) ..removeListener(_reparse)
..dispose(); ..dispose();
@@ -231,7 +225,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
TextPosition(offset: _controller.text.length), TextPosition(offset: _controller.text.length),
); );
_errorText = null; _errorText = null;
_entryMode = _RouteEntryMode.map; _showManualEditor = false;
}); });
_reparse(); _reparse();
} }
@@ -247,7 +241,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
TextPosition(offset: _controller.text.length), TextPosition(offset: _controller.text.length),
); );
_errorText = null; _errorText = null;
_entryMode = _RouteEntryMode.manual; _showManualEditor = true;
}); });
_reparse(); _reparse();
} }
@@ -416,17 +410,31 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
} }
Widget _buildPreviewSection() { Widget _buildPreviewSection() {
return Column( final colorScheme = Theme.of(context).colorScheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: colorScheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(
_parsedRoute == null ? 'Route preview' : _parsedRoute!.summary,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 4),
Text( Text(
_parsedRoute == null _parsedRoute == null
? 'Preview: enter or pick a route to validate it.' ? 'Pick relays from the list below or open manual edit if you need exact hop tokens.'
: 'Preview: ${_parsedRoute!.summary}${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}', : '${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
if (_parsedRoute != null) ...[ if (_parsedRoute != null) ...[
const SizedBox(height: 4), const SizedBox(height: 10),
SelectableText( SelectableText(
_parsedRoute!.canonicalText, _parsedRoute!.canonicalText,
style: Theme.of( style: Theme.of(
@@ -435,40 +443,146 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
), ),
], ],
], ],
),
);
}
Widget _buildSelectedHopSection() {
if (_selectedMapHops.isEmpty) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Theme.of(context).dividerColor),
),
child: Text(
'No relays selected yet. Add repeaters below or let auto resolve build a route.',
style: Theme.of(context).textTheme.bodyMedium,
),
); );
} }
Widget _buildBuilderTab(
BuildContext context, {
required List<Contact> routeCandidates,
required List<LatLng> mapPoints,
required List<LatLng> routePoints,
}) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SegmentedButton<_RouteEntryMode>( Text('Selected relays', style: Theme.of(context).textTheme.titleSmall),
segments: const [ const SizedBox(height: 8),
ButtonSegment<_RouteEntryMode>( ..._selectedMapHops.asMap().entries.map((entry) {
value: _RouteEntryMode.map, final index = entry.key;
icon: Icon(Icons.map_outlined), final contact = entry.value;
label: Text('Map'), return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(child: Text('${index + 1}')),
title: Text(contact.displayName),
subtitle: Text(
_tokenFor(contact, _selectedHashSize),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
), ),
ButtonSegment<_RouteEntryMode>( trailing: IconButton(
value: _RouteEntryMode.manual, tooltip: 'Remove relay',
icon: Icon(Icons.tune), onPressed: () => _toggleHop(contact),
label: Text('Manual'), icon: const Icon(Icons.close),
), ),
),
);
}),
], ],
selected: {_entryMode}, );
onSelectionChanged: (selection) { }
Widget _buildRelayPicker(List<Contact> routeCandidates) {
final query = _relaySearchController.text.trim().toLowerCase();
final filteredCandidates = routeCandidates.where((contact) {
if (query.isEmpty) return true;
return contact.displayName.toLowerCase().contains(query) ||
_tokenFor(contact, _selectedHashSize).toLowerCase().contains(query);
}).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _relaySearchController,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
prefixIcon: Icon(Icons.search),
labelText: 'Find relay',
hintText: 'Search by name or token',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
if (filteredCandidates.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Theme.of(context).dividerColor),
),
child: Text(
'No visible relays match this search.',
style: Theme.of(context).textTheme.bodyMedium,
),
)
else
...filteredCandidates.map((candidate) {
final isSelected = _selectedMapHops.any(
(item) => item.publicKeyHex == candidate.publicKeyHex,
);
final location = candidate.displayLocation;
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: Icon(
isSelected
? Icons.check_circle
: Icons.radio_button_unchecked,
color: isSelected
? Theme.of(context).colorScheme.primary
: null,
),
title: Text(candidate.displayName),
subtitle: Text(
[
_tokenFor(candidate, _selectedHashSize),
if (location != null)
'${location.latitude.toStringAsFixed(4)}, ${location.longitude.toStringAsFixed(4)}',
].join(''),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
),
trailing: TextButton(
onPressed: () => _toggleHop(candidate),
child: Text(isSelected ? 'Remove' : 'Add'),
),
),
);
}),
],
);
}
Widget _buildManualEditor() {
return ExpansionTile(
tilePadding: EdgeInsets.zero,
childrenPadding: EdgeInsets.zero,
initiallyExpanded: _showManualEditor,
onExpansionChanged: (expanded) {
setState(() { setState(() {
_entryMode = selection.first; _showManualEditor = expanded;
}); });
}, },
title: const Text('Manual route edit'),
subtitle: const Text(
'Use this when you need to paste or tweak hop tokens directly.',
), ),
const SizedBox(height: 16), children: [
if (_entryMode == _RouteEntryMode.manual) ...[
TextField( TextField(
controller: _controller, controller: _controller,
textCapitalization: TextCapitalization.characters, textCapitalization: TextCapitalization.characters,
@@ -480,48 +594,35 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
? 'AABB,CCDD' ? 'AABB,CCDD'
: 'AABBCC,DDEEFF', : 'AABBCC,DDEEFF',
helperText: helperText:
'Enter comma-separated hops. Path byte size comes from global Settings. Colon form like AA:BB is also accepted.', 'Comma-separated hops. Hash size follows global Settings. Colon form like AA:BB is also accepted.',
errorText: _errorText, errorText: _errorText,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
), ),
const SizedBox(height: 12),
_buildPreviewSection(),
] else ...[
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
OutlinedButton.icon(
onPressed: _resolvePathAutomatically,
icon: const Icon(Icons.auto_fix_high),
label: const Text('Resolve Path'),
),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 280),
child: Text(
'Tap repeaters on the map to build the path, then review the generated route below.',
style: Theme.of(context).textTheme.bodySmall,
),
),
], ],
), );
const SizedBox(height: 16), }
SizedBox(
height: 260, Widget _buildMapPreview({
child: ClipRRect( required List<Contact> routeCandidates,
borderRadius: BorderRadius.circular(12), required List<LatLng> mapPoints,
child: DecoratedBox( required List<LatLng> routePoints,
}) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor), borderRadius: BorderRadius.circular(16),
border: Border.all(color: colorScheme.outlineVariant),
), ),
clipBehavior: Clip.antiAlias,
child: SizedBox(
height: 220,
child: mapPoints.length < 2 child: mapPoints.length < 2
? const Center( ? const Center(
child: Padding( child: Padding(
padding: EdgeInsets.all(16), padding: EdgeInsets.all(16),
child: Text( child: Text(
'Map path builder needs your advertised location, the contact location, and visible repeater locations.', 'Map preview needs your advertised location, the contact location, and visible repeater locations.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
@@ -529,9 +630,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
: flutter_map.FlutterMap( : flutter_map.FlutterMap(
options: flutter_map.MapOptions( options: flutter_map.MapOptions(
initialCameraFit: flutter_map.CameraFit.bounds( initialCameraFit: flutter_map.CameraFit.bounds(
bounds: flutter_map.LatLngBounds.fromPoints( bounds: flutter_map.LatLngBounds.fromPoints(mapPoints),
mapPoints,
),
padding: const EdgeInsets.all(32), padding: const EdgeInsets.all(32),
), ),
), ),
@@ -547,7 +646,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
flutter_map.Polyline( flutter_map.Polyline(
points: routePoints, points: routePoints,
strokeWidth: 4, strokeWidth: 4,
color: Theme.of(context).colorScheme.primary, color: colorScheme.primary,
), ),
], ],
), ),
@@ -555,9 +654,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
markers: [ markers: [
...routeCandidates.map((candidate) { ...routeCandidates.map((candidate) {
final isSelected = _selectedMapHops.any( final isSelected = _selectedMapHops.any(
(item) => (item) => item.publicKeyHex == candidate.publicKeyHex,
item.publicKeyHex ==
candidate.publicKeyHex,
); );
return flutter_map.Marker( return flutter_map.Marker(
point: LatLng( point: LatLng(
@@ -569,14 +666,9 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
child: GestureDetector( child: GestureDetector(
onTap: () => _toggleHop(candidate), onTap: () => _toggleHop(candidate),
child: _RouteMarkerDot( child: _RouteMarkerDot(
label: _tokenFor( label: _tokenFor(candidate, _selectedHashSize),
candidate,
_selectedHashSize,
),
color: isSelected color: isSelected
? Theme.of( ? colorScheme.primary
context,
).colorScheme.primary
: Colors.blueGrey, : Colors.blueGrey,
), ),
), ),
@@ -587,34 +679,54 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
], ],
), ),
), ),
), );
), }
const SizedBox(height: 12),
if (_selectedMapHops.isNotEmpty) Widget _buildBuilderTab({
required List<Contact> routeCandidates,
required List<LatLng> mapPoints,
required List<LatLng> routePoints,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap( Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: _selectedMapHops.map((contact) { children: [
return InputChip( FilledButton.tonalIcon(
label: Text(contact.displayName), onPressed: _resolvePathAutomatically,
onDeleted: () => _toggleHop(contact), icon: const Icon(Icons.auto_fix_high),
); label: const Text('Auto resolve'),
}).toList(),
), ),
const SizedBox(height: 12), OutlinedButton.icon(
TextField( onPressed: _selectedMapHops.isEmpty
controller: _controller, ? null
readOnly: true, : () {
decoration: InputDecoration( setState(() {
labelText: 'Generated route', _selectedMapHops = const [];
helperText: 'Switch to Manual if you want to edit the hop list.', _syncControllerFromSelectedHops();
errorText: _errorText, });
border: const OutlineInputBorder(), },
icon: const Icon(Icons.clear_all),
label: const Text('Clear relays'),
), ),
),
const SizedBox(height: 12),
_buildPreviewSection(),
], ],
),
const SizedBox(height: 16),
_buildPreviewSection(),
const SizedBox(height: 16),
_buildSelectedHopSection(),
const SizedBox(height: 16),
_buildRelayPicker(routeCandidates),
const SizedBox(height: 16),
_buildMapPreview(
routeCandidates: routeCandidates,
mapPoints: mapPoints,
routePoints: routePoints,
),
const SizedBox(height: 16),
_buildManualEditor(),
], ],
); );
} }
@@ -720,39 +832,33 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
return DefaultTabController( return DefaultTabController(
length: 2, length: 2,
child: FractionallySizedBox( child: Scaffold(
heightFactor: 0.85, appBar: AppBar(
child: Padding( title: Text('Set Path for ${widget.contact.displayName}'),
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), bottom: const TabBar(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Set Route for ${widget.contact.displayName}',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Choose how to build the route, or reuse one from history.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
const TabBar(
tabs: [ tabs: [
Tab(text: 'Build'), Tab(text: 'Build'),
Tab(text: 'History'), Tab(text: 'History'),
], ],
), ),
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Plan the route on its own screen, then save it once the preview looks right.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Expanded( Expanded(
child: TabBarView( child: TabBarView(
children: [ children: [
SingleChildScrollView( ListView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildBuilderTab( _buildBuilderTab(
context,
routeCandidates: routeCandidates, routeCandidates: routeCandidates,
mapPoints: mapPoints, mapPoints: mapPoints,
routePoints: routePoints, routePoints: routePoints,
@@ -772,29 +878,40 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
clearPathOnMaxRetry: clearPathOnMaxRetry:
appProvider.clearPathOnMaxRetry, appProvider.clearPathOnMaxRetry,
), ),
const SizedBox(height: 24),
],
),
ListView(
children: [
_buildHistoryTab(),
const SizedBox(height: 24),
],
),
], ],
), ),
), ),
SingleChildScrollView(child: _buildHistoryTab()),
], ],
), ),
), ),
OverflowBar( ),
bottomNavigationBar: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: OverflowBar(
alignment: MainAxisAlignment.spaceBetween, alignment: MainAxisAlignment.spaceBetween,
spacing: 8, spacing: 8,
overflowSpacing: 8, overflowSpacing: 8,
children: [ children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
if (widget.contact.routeHasPath) if (widget.contact.routeHasPath)
TextButton( TextButton(
onPressed: () => Navigator.of( onPressed: () => Navigator.of(
context, context,
).pop(const ContactRouteDialogResult.clear()), ).pop(const ContactRouteDialogResult.clear()),
child: const Text('Clear Route'), child: const Text('Clear Route'),
), )
else
const SizedBox.shrink(),
FilledButton( FilledButton(
onPressed: _parsedRoute == null onPressed: _parsedRoute == null
? null ? null
@@ -805,11 +922,10 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
_buildSyntheticFallbackLocation(), _buildSyntheticFallbackLocation(),
), ),
), ),
child: const Text('Set Route'), child: const Text('Save Path'),
), ),
], ],
), ),
],
), ),
), ),
), ),
@@ -817,8 +933,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
} }
} }
enum _RouteEntryMode { map, manual }
class _RouteMarkerDot extends StatelessWidget { class _RouteMarkerDot extends StatelessWidget {
final String label; final String label;
final Color color; final Color color;

View File

@@ -325,7 +325,9 @@ class ContactTile extends StatelessWidget {
contact.type == ContactType.room || contact.type == ContactType.room ||
contact.type == ContactType.channel; contact.type == ContactType.channel;
final canSetPath = final canSetPath =
contact.type == ContactType.chat || contact.type == ContactType.room; contact.type == ContactType.chat ||
contact.type == ContactType.room ||
contact.type == ContactType.repeater;
final canAddToSensors = final canAddToSensors =
contact.type == ContactType.chat || contact.type == ContactType.chat ||
contact.type == ContactType.repeater; contact.type == ContactType.repeater;

View File

@@ -131,7 +131,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text( child: Text(
trace.routeHashes.isEmpty trace.routeHashes.isEmpty
? 'Direct route to ${widget.contact.displayName}' ? 'No relay path saved for ${widget.contact.displayName}'
: 'Route from saved contact path (${trace.routeHashes.length} hop${trace.routeHashes.length == 1 ? '' : 's'})', : 'Route from saved contact path (${trace.routeHashes.length} hop${trace.routeHashes.length == 1 ? '' : 's'})',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
@@ -199,14 +199,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
child: CircleAvatar( child: CircleAvatar(
radius: 16, radius: 16,
backgroundColor: backgroundColor:
entry.key == 0 Colors.blue,
? Colors.green
: (entry.key ==
concreteNodes
.length -
1
? Colors.red
: Colors.blue),
child: Text( child: Text(
'${entry.key + 1}', '${entry.key + 1}',
style: const TextStyle( style: const TextStyle(
@@ -236,7 +229,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text( child: Text(
'Route', 'Relay path',
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
), ),
@@ -263,11 +256,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
: null, : null,
leading: CircleAvatar( leading: CircleAvatar(
radius: 14, radius: 14,
backgroundColor: entry.key == 0 backgroundColor: Colors.blue,
? Colors.green
: (entry.key == routeEntries.length - 1
? Colors.red
: Colors.blue),
child: Text( child: Text(
'${entry.key + 1}', '${entry.key + 1}',
style: const TextStyle( style: const TextStyle(
@@ -279,7 +268,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
), ),
title: Text(entry.value.label), title: Text(entry.value.label),
subtitle: Text( subtitle: Text(
'${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : '${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : '${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : '${entry.value.resolved.cycleSummary}'}', 'Relay${entry.value.keyLabel == null ? '' : '${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : '${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : '${entry.value.resolved.cycleSummary}'}',
), ),
trailing: entry.value.resolved.canCycle trailing: entry.value.resolved.canCycle
? const Icon(Icons.sync_alt) ? const Icon(Icons.sync_alt)
@@ -326,17 +315,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
} }
List<_RouteDisplayEntry> _displayRouteEntries(_ContactTraceResult trace) { List<_RouteDisplayEntry> _displayRouteEntries(_ContactTraceResult trace) {
final entries = <_RouteDisplayEntry>[]; return trace.matchedRelayNodes.asMap().entries.map((entry) {
if (trace.sender.node != null) {
entries.add(
_RouteDisplayEntry.fromResolved(
trace.sender,
target: const _RouteEntryTarget.sender(),
),
);
}
entries.addAll(
trace.matchedRelayNodes.asMap().entries.map((entry) {
final resolved = entry.value; final resolved = entry.value;
final node = resolved.node; final node = resolved.node;
final hashHex = trace.routeHashes[entry.key].toUpperCase(); final hashHex = trace.routeHashes[entry.key].toUpperCase();
@@ -347,23 +326,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
matchSummary: resolved.matchSummary, matchSummary: resolved.matchSummary,
target: _RouteEntryTarget.relayNode(entry.key), target: _RouteEntryTarget.relayNode(entry.key),
); );
}), }).toList();
);
if (trace.recipient.node != null) {
entries.add(
_RouteDisplayEntry.fromResolved(
trace.recipient,
target: const _RouteEntryTarget.recipient(),
),
);
}
return entries;
}
String _routeRoleLabel(int index, int total) {
if (index == 0) return 'Sender';
if (index == total - 1) return 'Recipient';
return 'Relay';
} }
String _prefixKeyLabel(String publicKey) => String _prefixKeyLabel(String publicKey) =>
@@ -527,20 +490,6 @@ class _ContactTraceResult {
_ContactTraceResult cycleEntry(_RouteEntryTarget target) { _ContactTraceResult cycleEntry(_RouteEntryTarget target) {
switch (target.kind) { switch (target.kind) {
case _RouteEntryKind.sender:
return _ContactTraceResult(
sender: sender.cycle(),
recipient: recipient,
routeHashes: routeHashes,
matchedRelayNodes: matchedRelayNodes,
);
case _RouteEntryKind.recipient:
return _ContactTraceResult(
sender: sender,
recipient: recipient.cycle(),
routeHashes: routeHashes,
matchedRelayNodes: matchedRelayNodes,
);
case _RouteEntryKind.relayNode: case _RouteEntryKind.relayNode:
final updated = matchedRelayNodes.toList(); final updated = matchedRelayNodes.toList();
updated[target.index] = updated[target.index].cycle(); updated[target.index] = updated[target.index].cycle();
@@ -570,26 +519,9 @@ class _RouteDisplayEntry {
}); });
MeshMapNode? get node => resolved.node; MeshMapNode? get node => resolved.node;
factory _RouteDisplayEntry.fromResolved(
ResolvedTraceNode resolved, {
required _RouteEntryTarget target,
}) {
final node = resolved.node!;
return _RouteDisplayEntry(
resolved: resolved,
label: node.name,
keyLabel: node.publicKey.substring(
0,
math.min(12, node.publicKey.length),
),
matchSummary: resolved.matchSummary,
target: target,
);
}
} }
enum _RouteEntryKind { sender, recipient, relayNode } enum _RouteEntryKind { relayNode }
class _RouteEntryTarget { class _RouteEntryTarget {
final _RouteEntryKind kind; final _RouteEntryKind kind;
@@ -597,8 +529,6 @@ class _RouteEntryTarget {
const _RouteEntryTarget._(this.kind, [this.index = 0]); const _RouteEntryTarget._(this.kind, [this.index = 0]);
const _RouteEntryTarget.sender() : this._(_RouteEntryKind.sender);
const _RouteEntryTarget.recipient() : this._(_RouteEntryKind.recipient);
const _RouteEntryTarget.relayNode(int index) const _RouteEntryTarget.relayNode(int index)
: this._(_RouteEntryKind.relayNode, index); : this._(_RouteEntryKind.relayNode, index);
} }

View File

@@ -45,6 +45,7 @@ import 'system_message_bubble.dart';
/// - Grouped messages (expandable recipient list) /// - Grouped messages (expandable recipient list)
class MessageBubble extends StatefulWidget { class MessageBubble extends StatefulWidget {
final Message message; final Message message;
final int receivedCopies;
final VoidCallback? onTap; final VoidCallback? onTap;
final VoidCallback? onReply; final VoidCallback? onReply;
final bool isHighlighted; final bool isHighlighted;
@@ -56,6 +57,7 @@ class MessageBubble extends StatefulWidget {
const MessageBubble({ const MessageBubble({
super.key, super.key,
required this.message, required this.message,
this.receivedCopies = 1,
this.onTap, this.onTap,
this.onReply, this.onReply,
this.isHighlighted = false, this.isHighlighted = false,
@@ -576,6 +578,7 @@ class _MessageBubbleState extends State<MessageBubble> {
'Sender to receipt ms: ${receptionDetails?.senderToReceiptMs ?? '-'}', 'Sender to receipt ms: ${receptionDetails?.senderToReceiptMs ?? '-'}',
'Estimated transmit ms: ${receptionDetails?.estimatedTransmitMs ?? '-'}', 'Estimated transmit ms: ${receptionDetails?.estimatedTransmitMs ?? '-'}',
'Post-transmit delay ms: ${receptionDetails?.postTransmitDelayMs ?? '-'}', 'Post-transmit delay ms: ${receptionDetails?.postTransmitDelayMs ?? '-'}',
'Received copies: ${widget.receivedCopies}',
'Expected ACK tag: ${widget.message.expectedAckTag ?? '-'}', 'Expected ACK tag: ${widget.message.expectedAckTag ?? '-'}',
'Suggested timeout ms: ${widget.message.suggestedTimeoutMs ?? '-'}', 'Suggested timeout ms: ${widget.message.suggestedTimeoutMs ?? '-'}',
'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}', 'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}',
@@ -846,6 +849,12 @@ class _MessageBubbleState extends State<MessageBubble> {
receptionDetails!.postTransmitDelayMs!, receptionDetails!.postTransmitDelayMs!,
), ),
), ),
if (widget.receivedCopies > 1)
_detailRow(
context,
label: 'Received copies',
value: '${widget.receivedCopies}',
),
if (widget.message.suggestedTimeoutMs != null) if (widget.message.suggestedTimeoutMs != null)
_detailRow( _detailRow(
context, context,

View File

@@ -154,8 +154,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text( child: Text(
trace.mode == TraceMode.packetPath trace.mode == TraceMode.packetPath
? 'Route from packet path bytes' ? 'Relay path from packet path bytes'
: 'Route inferred from hop count (${widget.message.pathLen})', : 'Relay path inferred from hop count (${widget.message.pathLen})',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
), ),
@@ -222,14 +222,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
child: CircleAvatar( child: CircleAvatar(
radius: 16, radius: 16,
backgroundColor: backgroundColor:
entry.key == 0 Colors.blue,
? Colors.green
: (entry.key ==
concretePathNodes
.length -
1
? Colors.red
: Colors.blue),
child: Text( child: Text(
'${entry.key + 1}', '${entry.key + 1}',
style: const TextStyle( style: const TextStyle(
@@ -259,7 +252,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text( child: Text(
'Route', 'Relay path',
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
), ),
@@ -286,11 +279,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
: null, : null,
leading: CircleAvatar( leading: CircleAvatar(
radius: 14, radius: 14,
backgroundColor: entry.key == 0 backgroundColor: Colors.blue,
? Colors.green
: (entry.key == routeEntries.length - 1
? Colors.red
: Colors.blue),
child: Text( child: Text(
'${entry.key + 1}', '${entry.key + 1}',
style: const TextStyle( style: const TextStyle(
@@ -302,7 +291,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
), ),
title: Text(entry.value.label), title: Text(entry.value.label),
subtitle: Text( subtitle: Text(
'${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : '${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : '${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : '${entry.value.resolved.cycleSummary}'}', 'Relay${entry.value.keyLabel == null ? '' : '${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : '${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : '${entry.value.resolved.cycleSummary}'}',
), ),
trailing: entry.value.resolved.canCycle trailing: entry.value.resolved.canCycle
? const Icon(Icons.sync_alt) ? const Icon(Icons.sync_alt)
@@ -365,28 +354,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
} }
List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) { List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) {
final pathNodes = trace.matchedPathNodes
.map((entry) => entry.node)
.whereType<MeshMapNode>()
.toList();
if (pathNodes.isEmpty) {
return [
if (trace.sender.node != null)
_RouteDisplayEntry.fromResolved(
trace.sender,
target: const _RouteEntryTarget.sender(),
),
if (trace.recipient.node != null &&
trace.recipient.node!.publicKey != trace.sender.node?.publicKey)
_RouteDisplayEntry.fromResolved(
trace.recipient,
target: const _RouteEntryTarget.recipient(),
),
];
}
if (trace.mode == TraceMode.packetPath) { if (trace.mode == TraceMode.packetPath) {
final entries = trace.matchedPathNodes.asMap().entries.map((entry) { return trace.matchedPathNodes.asMap().entries.map((entry) {
final hashHex = trace.pathHashes[entry.key].toUpperCase(); final hashHex = trace.pathHashes[entry.key].toUpperCase();
return _RouteDisplayEntry( return _RouteDisplayEntry(
resolved: entry.value, resolved: entry.value,
@@ -398,27 +367,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
target: _RouteEntryTarget.pathNode(entry.key), target: _RouteEntryTarget.pathNode(entry.key),
); );
}).toList(); }).toList();
final lastKey = pathNodes.last.publicKey;
return [
...entries,
if (trace.recipient.node != null &&
trace.recipient.node!.publicKey != lastKey)
_RouteDisplayEntry.fromResolved(
trace.recipient,
target: const _RouteEntryTarget.recipient(),
),
];
} }
final firstKey = pathNodes.first.publicKey; return trace.matchedPathNodes
final lastKey = pathNodes.last.publicKey;
return [
if (trace.sender.node != null && trace.sender.node!.publicKey != firstKey)
_RouteDisplayEntry.fromResolved(
trace.sender,
target: const _RouteEntryTarget.sender(),
),
...trace.matchedPathNodes
.asMap() .asMap()
.entries .entries
.where((entry) => entry.value.node != null) .where((entry) => entry.value.node != null)
@@ -427,25 +378,13 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
entry.value, entry.value,
target: _RouteEntryTarget.pathNode(entry.key), target: _RouteEntryTarget.pathNode(entry.key),
), ),
), )
if (trace.recipient.node != null && .toList();
trace.recipient.node!.publicKey != lastKey)
_RouteDisplayEntry.fromResolved(
trace.recipient,
target: const _RouteEntryTarget.recipient(),
),
];
} }
String _prefixKeyLabel(String publicKey) => String _prefixKeyLabel(String publicKey) =>
publicKey.substring(0, math.min(12, publicKey.length)); publicKey.substring(0, math.min(12, publicKey.length));
String _routeRoleLabel(int index, int total) {
if (index == 0) return 'Sender';
if (index == total - 1) return 'Recipient';
return 'Relay';
}
String? _toPrefixHex(List<int>? key) { String? _toPrefixHex(List<int>? key) {
if (key == null || key.isEmpty) return null; if (key == null || key.isEmpty) return null;
final take = key.length < 6 ? key.length : 6; final take = key.length < 6 ? key.length : 6;
@@ -521,7 +460,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
final hopHashes = LogRxRouteDecoder.splitHopHashes( final hopHashes = LogRxRouteDecoder.splitHopHashes(
packetPath, packetPath,
hashSize: hashSize, hashSize: hashSize,
).reversed.toList(); );
final matched = _matchNodesFromPathHashes( final matched = _matchNodesFromPathHashes(
nodes: nodes, nodes: nodes,
localPublicKeys: localPublicKeys, localPublicKeys: localPublicKeys,
@@ -731,22 +670,6 @@ class _TraceResult {
_TraceResult cycleEntry(_RouteEntryTarget target) { _TraceResult cycleEntry(_RouteEntryTarget target) {
switch (target.kind) { switch (target.kind) {
case _RouteEntryKind.sender:
return _TraceResult(
mode: mode,
sender: sender.cycle(),
recipient: recipient,
pathHashes: pathHashes,
matchedPathNodes: matchedPathNodes,
);
case _RouteEntryKind.recipient:
return _TraceResult(
mode: mode,
sender: sender,
recipient: recipient.cycle(),
pathHashes: pathHashes,
matchedPathNodes: matchedPathNodes,
);
case _RouteEntryKind.pathNode: case _RouteEntryKind.pathNode:
final updated = matchedPathNodes.toList(); final updated = matchedPathNodes.toList();
updated[target.index] = updated[target.index].cycle(); updated[target.index] = updated[target.index].cycle();
@@ -796,7 +719,7 @@ class _RouteDisplayEntry {
} }
} }
enum _RouteEntryKind { sender, recipient, pathNode } enum _RouteEntryKind { pathNode }
class _RouteEntryTarget { class _RouteEntryTarget {
final _RouteEntryKind kind; final _RouteEntryKind kind;
@@ -804,8 +727,6 @@ class _RouteEntryTarget {
const _RouteEntryTarget._(this.kind, [this.index = 0]); const _RouteEntryTarget._(this.kind, [this.index = 0]);
const _RouteEntryTarget.sender() : this._(_RouteEntryKind.sender);
const _RouteEntryTarget.recipient() : this._(_RouteEntryKind.recipient);
const _RouteEntryTarget.pathNode(int index) const _RouteEntryTarget.pathNode(int index)
: this._(_RouteEntryKind.pathNode, index); : this._(_RouteEntryKind.pathNode, index);
} }

View File

@@ -2,13 +2,14 @@ import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../models/message.dart'; import '../../models/message.dart';
import '../../providers/messages_provider.dart';
import '../common/bidirectional_refresh.dart'; import '../common/bidirectional_refresh.dart';
import '../../widgets/messages/message_bubble.dart'; import '../../widgets/messages/message_bubble.dart';
class MessagesContent extends StatelessWidget { class MessagesContent extends StatelessWidget {
static const double defaultPadding = 8; static const double defaultPadding = 8;
final List<Message> messages; final List<DisplayMessageEntry> messages;
final ScrollController scrollController; final ScrollController scrollController;
final String? highlightedMessageId; final String? highlightedMessageId;
final double bottomContentPadding; final double bottomContentPadding;
@@ -84,11 +85,13 @@ class MessagesContent extends StatelessWidget {
), ),
itemCount: messages.length, itemCount: messages.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final message = messages[index]; final entry = messages[index];
final message = entry.message;
return MessageBubble( return MessageBubble(
key: ValueKey(message.id), key: ValueKey(message.id),
message: message, message: message,
receivedCopies: entry.occurrenceCount,
isHighlighted: message.id == highlightedMessageId, isHighlighted: message.id == highlightedMessageId,
onNavigateToMap: onNavigateToMap, onNavigateToMap: onNavigateToMap,
onTap: onMessageTap == null onTap: onMessageTap == null

View File

@@ -4,6 +4,7 @@ import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart'; import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/message.dart'; import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/models/message_reception_details.dart';
import 'package:meshcore_sar_app/models/path_selection.dart'; import 'package:meshcore_sar_app/models/path_selection.dart';
import 'package:meshcore_sar_app/providers/helpers/message_retry_manager.dart'; import 'package:meshcore_sar_app/providers/helpers/message_retry_manager.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart'; import 'package:meshcore_sar_app/providers/messages_provider.dart';
@@ -347,6 +348,88 @@ void main() {
expect(provider.messages.single.id, equals('c-prefix')); expect(provider.messages.single.id, equals('c-prefix'));
}); });
test('duplicate incoming message increments received copy count', () {
final provider = MessagesProvider();
final sender = Uint8List.fromList([7, 7, 4, 1, 7, 2]);
provider.addMessage(
Message(
id: 'copy-1',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000600,
text: 'same payload',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: sender,
),
receptionDetailsSnapshot: MessageReceptionDetails(
capturedAt: DateTime.now(),
),
);
provider.addMessage(
Message(
id: 'copy-2',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000601,
text: 'same payload',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: sender,
),
receptionDetailsSnapshot: MessageReceptionDetails(
capturedAt: DateTime.now(),
),
);
expect(provider.messages, hasLength(1));
expect(
provider.getMessageReceptionDetails('copy-1')?.receivedCopies,
equals(2),
);
});
test('display list collapses stored duplicates and sums copy counts', () {
final provider = MessagesProvider();
final sender = Uint8List.fromList([5, 4, 3, 2, 1, 0]);
provider.addMessages([
Message(
id: 'dup-a',
messageType: MessageType.channel,
channelIdx: 0,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000705,
text: 'same payload',
senderName: 'Klemen',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: sender,
),
Message(
id: 'dup-b',
messageType: MessageType.channel,
channelIdx: 0,
pathLen: 2,
textType: MessageTextType.plain,
senderTimestamp: 1700000700,
text: 'same payload',
senderName: 'Klemen',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: sender,
),
]);
final display = provider.buildDisplayMessages(
provider.getRecentMessages(),
);
expect(display, hasLength(1));
expect(display.single.message.id, equals('dup-a'));
expect(display.single.occurrenceCount, equals(2));
});
test('missing ACK schedules a delayed retransmission', () { test('missing ACK schedules a delayed retransmission', () {
fakeAsync((async) { fakeAsync((async) {
final provider = MessagesProvider(); final provider = MessagesProvider();

View File

@@ -78,6 +78,7 @@ void main() {
senderToReceiptMs: 1200, senderToReceiptMs: 1200,
estimatedTransmitMs: 800, estimatedTransmitMs: 800,
postTransmitDelayMs: 400, postTransmitDelayMs: 400,
receivedCopies: 3,
), ),
}, },
); );
@@ -95,6 +96,7 @@ void main() {
expect(restored.senderToReceiptMs, 1200); expect(restored.senderToReceiptMs, 1200);
expect(restored.estimatedTransmitMs, 800); expect(restored.estimatedTransmitMs, 800);
expect(restored.postTransmitDelayMs, 400); expect(restored.postTransmitDelayMs, 400);
expect(restored.receivedCopies, 3);
expect( expect(
restored.packetLoggedAt, restored.packetLoggedAt,
DateTime.fromMillisecondsSinceEpoch(1700000100400), DateTime.fromMillisecondsSinceEpoch(1700000100400),