From 6de12225fc947f7988ff2f32ffcaa1bd078c83dc Mon Sep 17 00:00:00 2001 From: Janez T Date: Sat, 14 Mar 2026 21:04:23 +0100 Subject: [PATCH] feat: Finish discovery flow ref: --- lib/main.dart | 30 + lib/models/device_info.dart | 21 + lib/providers/app_provider.dart | 538 +++++++++++++++- lib/providers/connection_provider.dart | 295 ++++++++- lib/providers/contacts_provider.dart | 255 +++++++- lib/screens/device_config_screen.dart | 220 ++++++- lib/screens/discovery_screen.dart | 587 +++++++++++++++--- lib/screens/live_traffic_screen.dart | 371 ++++------- lib/screens/messages_tab.dart | 51 +- lib/services/notification_service.dart | 19 +- lib/widgets/compact_signal_indicator.dart | 106 ++++ lib/widgets/messages/message_trace_sheet.dart | 75 ++- pubspec.lock | 4 +- pubspec.yaml | 2 +- 14 files changed, 2170 insertions(+), 404 deletions(-) create mode 100644 lib/widgets/compact_signal_indicator.dart diff --git a/lib/main.dart b/lib/main.dart index 092e388..424aa04 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -25,6 +25,7 @@ import 'services/locale_preferences.dart'; import 'services/mesh_map_nodes_service.dart'; import 'services/update_checker_service.dart'; import 'services/wizard_preferences.dart'; +import 'screens/discovery_screen.dart'; import 'screens/home_screen.dart'; import 'screens/welcome_wizard_screen.dart'; import 'theme/app_theme.dart'; @@ -42,11 +43,13 @@ class MeshCoreSarApp extends StatefulWidget { } class _MeshCoreSarAppState extends State { + final GlobalKey _navigatorKey = GlobalKey(); AppThemeMode _themeMode = AppThemeMode.system; Locale? _locale; bool _isInitialized = false; bool _shouldShowPermissionDialog = false; bool _wizardCompleted = true; // Will be updated in _initializeApp() + String? _pendingNotificationPayload; @override void initState() { @@ -66,6 +69,7 @@ class _MeshCoreSarAppState extends State { // Set up notification tap handler for update notifications NotificationService().onNotificationTapped = _handleNotificationTap; + _pendingNotificationPayload = NotificationService().consumeLaunchPayload(); // Check if we need to request location permissions await _checkLocationPermissions(); @@ -81,6 +85,14 @@ class _MeshCoreSarAppState extends State { _wizardCompleted = wizardCompleted; _isInitialized = true; }); + + if (_pendingNotificationPayload != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final payload = _pendingNotificationPayload; + _pendingNotificationPayload = null; + _handleNotificationTap(payload); + }); + } } /// Handle notification tap @@ -89,10 +101,27 @@ class _MeshCoreSarAppState extends State { debugPrint('[Main] Notification tapped: $payload'); + if (!_wizardCompleted) { + _pendingNotificationPayload = payload; + return; + } + // Handle update notification tap if (payload.startsWith('update:')) { final downloadUrl = payload.substring(7); // Remove 'update:' prefix _launchUpdateDownload(downloadUrl); + return; + } + + if (payload.startsWith('discovery:')) { + final navigator = _navigatorKey.currentState; + if (navigator == null) { + _pendingNotificationPayload = payload; + return; + } + navigator.push( + MaterialPageRoute(builder: (context) => const DiscoveryScreen()), + ); } // SAR and message notifications handled by their respective providers } @@ -304,6 +333,7 @@ class _MeshCoreSarAppState extends State { builder: (context) { final systemBrightness = MediaQuery.platformBrightnessOf(context); final materialApp = MaterialApp( + navigatorKey: _navigatorKey, key: ValueKey( '${_locale?.languageCode ?? 'system'}_${_themeMode.name}', ), diff --git a/lib/models/device_info.dart b/lib/models/device_info.dart index f067564..22b59fe 100644 --- a/lib/models/device_info.dart +++ b/lib/models/device_info.dart @@ -66,6 +66,11 @@ class DeviceInfo { final int? advLat; final int? advLon; final bool? manualAddContacts; + final bool? autoAddUsers; + final bool? autoAddRepeaters; + final bool? autoAddRoomServers; + final bool? autoAddSensors; + final bool? autoAddOverwriteOldest; final int? radioFreq; final int? radioBw; final int? radioSf; @@ -113,6 +118,11 @@ class DeviceInfo { this.advLat, this.advLon, this.manualAddContacts, + this.autoAddUsers, + this.autoAddRepeaters, + this.autoAddRoomServers, + this.autoAddSensors, + this.autoAddOverwriteOldest, this.radioFreq, this.radioBw, this.radioSf, @@ -239,6 +249,11 @@ class DeviceInfo { int? advLat, int? advLon, bool? manualAddContacts, + bool? autoAddUsers, + bool? autoAddRepeaters, + bool? autoAddRoomServers, + bool? autoAddSensors, + bool? autoAddOverwriteOldest, int? radioFreq, int? radioBw, int? radioSf, @@ -278,6 +293,12 @@ class DeviceInfo { advLat: advLat ?? this.advLat, advLon: advLon ?? this.advLon, manualAddContacts: manualAddContacts ?? this.manualAddContacts, + autoAddUsers: autoAddUsers ?? this.autoAddUsers, + autoAddRepeaters: autoAddRepeaters ?? this.autoAddRepeaters, + autoAddRoomServers: autoAddRoomServers ?? this.autoAddRoomServers, + autoAddSensors: autoAddSensors ?? this.autoAddSensors, + autoAddOverwriteOldest: + autoAddOverwriteOldest ?? this.autoAddOverwriteOldest, radioFreq: radioFreq ?? this.radioFreq, radioBw: radioBw ?? this.radioBw, radioSf: radioSf ?? this.radioSf, diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 5e8125c..296d864 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:math' as math; import 'package:flutter/foundation.dart'; +import 'package:meshcore_client/meshcore_client.dart' show BufferReader; import 'package:shared_preferences/shared_preferences.dart'; import 'connection_provider.dart'; import 'contacts_provider.dart'; @@ -57,12 +59,65 @@ class _DirectMessageRouteSession { } } +class _ParsedRawAdvert { + final Uint8List publicKey; + final String? advName; + final int typeValue; + final int flags; + final int lastAdvert; + final int? advLat; + final int? advLon; + final int? signedEncodedPathLen; + final Uint8List? paddedPathBytes; + + const _ParsedRawAdvert({ + required this.publicKey, + required this.advName, + required this.typeValue, + required this.flags, + required this.lastAdvert, + required this.advLat, + required this.advLon, + required this.signedEncodedPathLen, + required this.paddedPathBytes, + }); +} + +class _ParsedRepeaterStatus { + final int batteryMv; + final int queueLen; + final int lastRssi; + final int lastSnrRaw; + final int uptimeSecs; + + const _ParsedRepeaterStatus({ + required this.batteryMv, + required this.queueLen, + required this.lastRssi, + required this.lastSnrRaw, + required this.uptimeSecs, + }); +} + +class _PendingRepeaterOwnerRequest { + final Uint8List publicKey; + + const _PendingRepeaterOwnerRequest({required this.publicKey}); +} + /// Main App Provider - coordinates all other providers class AppProvider with ChangeNotifier { static const int _maxDirectPayloadHops = 3; + static const int _rawPayloadTypeAdvert = 0x04; + static const int _routeTransportFlood = 0x00; + static const int _routeTransportDirect = 0x03; + static const int _anonReqTypeOwner = 0x02; static const double _lowBatteryThresholdPercent = 30.0; static const double _lowBatteryResetThresholdPercent = 35.0; static const Duration _lowBatteryCheckInterval = Duration(minutes: 5); + static const Duration _repeaterOwnerInfoRequestCooldown = Duration( + minutes: 10, + ); @visibleForTesting static bool isDeletedChannelInfo( int channelIdx, @@ -151,6 +206,10 @@ class AppProvider with ChangeNotifier { final Map> _pendingMediaSwarmFetches = {}; final Map> _pendingMediaSwarmResponses = {}; + final Map _recentRepeaterStatusRequests = {}; + final Map _recentRepeaterOwnerInfoRequests = {}; + final Map _pendingRepeaterOwnerRequests = + {}; bool _fastLocationScreenActive = false; Timer? _packetCaptureFlushTimer; Timer? _lowBatteryCheckTimer; @@ -864,11 +923,47 @@ class AppProvider with ChangeNotifier { ); }; // When a contact is received from BLE - connectionProvider.onContactReceived = (contact) { + connectionProvider.onContactReceivedDetailed = (contact, source) { + final devicePublicKey = connectionProvider.deviceInfo.publicKey; + final existingContact = contactsProvider.findContactByKey( + contact.publicKey, + ); + final isAdvertSource = + source == ContactReceiveSource.advert || + source == ContactReceiveSource.preview; + + if (isAdvertSource) { + final isNewPendingAdvert = contactsProvider + .addOrUpdatePendingAdvertContact( + contact, + devicePublicKey: devicePublicKey, + ); + if (isNewPendingAdvert) { + unawaited( + _notificationService.showContactDiscoveredNotification( + contactKey: contact.publicKey + .take(6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(), + contactName: contact.advName.trim().isEmpty + ? null + : contact.advName, + ), + ); + } + if (contact.type == ContactType.repeater && + contact.advName.trim().isEmpty) { + unawaited(_maybeRequestRepeaterOwnerInfo(contact.publicKey)); + } + if (existingContact == null) { + return; + } + } + // Pass device public key to filter out our own contact contactsProvider.addOrUpdateContact( contact, - devicePublicKey: connectionProvider.deviceInfo.publicKey, + devicePublicKey: devicePublicKey, ); unawaited(_pathHistoryService.recordLearnedPath(contact)); @@ -1240,6 +1335,9 @@ class AppProvider with ChangeNotifier { // Used by newer firmware versions for telemetry and other binary data // BOTH callbacks (0x8B and 0x8C) must be handled for device compatibility connectionProvider.onBinaryResponse = (publicKeyPrefix, tag, responseData) { + if (_handlePendingRepeaterOwnerResponse(tag, responseData)) { + return; + } debugPrint( '📊 [AppProvider] Binary response (0x8C) received - updating contact telemetry', ); @@ -1253,6 +1351,43 @@ class AppProvider with ChangeNotifier { // Magic 0x69 'i' = image fetch request; 0x56 'V' = voice packet. // Magic 0x49 'I' = image packet. connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) { + final parsedAdvert = _tryParseRawAdvert(payload); + if (parsedAdvert != null) { + final isNewPendingAdvert = contactsProvider.addOrUpdatePendingAdvertMetadata( + publicKey: parsedAdvert.publicKey, + typeValue: parsedAdvert.typeValue, + devicePublicKey: connectionProvider.deviceInfo.publicKey, + flags: parsedAdvert.flags, + advName: parsedAdvert.advName, + lastAdvert: parsedAdvert.lastAdvert, + advLat: parsedAdvert.advLat, + advLon: parsedAdvert.advLon, + signedEncodedPathLen: parsedAdvert.signedEncodedPathLen, + paddedPathBytes: parsedAdvert.paddedPathBytes, + rxRssiDbm: rssiDbm, + rxSnrRaw: snrRaw, + ); + if (isNewPendingAdvert) { + final contactKey = parsedAdvert.publicKey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + unawaited( + _notificationService.showContactDiscoveredNotification( + contactKey: contactKey, + contactName: parsedAdvert.advName, + ), + ); + } + if (parsedAdvert.typeValue == ContactType.repeater.value) { + unawaited(_requestRepeaterStatus(parsedAdvert.publicKey)); + unawaited(_maybeRequestRepeaterOwnerInfo(parsedAdvert.publicKey)); + } + if (contactsProvider.shouldEnrichPendingAdvert(parsedAdvert.publicKey)) { + unawaited(connectionProvider.previewContact(parsedAdvert.publicKey)); + } + return; + } + final fastGpsPacket = FastGpsPacket.tryParseBinary(payload); if (fastGpsPacket != null) { final sender = _resolveContactByPrefixHex(fastGpsPacket.senderKey6); @@ -1458,6 +1593,31 @@ class AppProvider with ChangeNotifier { _handleIncomingVoicePacket(pkt, justComplete: justComplete); }; + connectionProvider.onControlDataReceived = + (payload, snrRaw, rssiDbm, pathLen) { + _handleControlDataDiscovery( + payload: payload, + snrRaw: snrRaw, + rssiDbm: rssiDbm, + pathLen: pathLen, + ); + }; + + connectionProvider.onStatusResponse = (publicKeyPrefix, statusData) { + final parsed = _tryParseRepeaterStatus(statusData); + if (parsed == null) { + return; + } + contactsProvider.updatePendingAdvertStatusByPrefix( + publicKeyPrefix, + batteryMv: parsed.batteryMv, + queueLen: parsed.queueLen, + lastRssi: parsed.lastRssi, + lastSnrRaw: parsed.lastSnrRaw, + uptimeSecs: parsed.uptimeSecs, + ); + }; + // When a contact's routing path is updated in the mesh network connectionProvider.onPathUpdated = (publicKey) { debugPrint( @@ -1509,9 +1669,13 @@ class AppProvider with ChangeNotifier { unawaited( _notificationService.showContactDiscoveredNotification( contactKey: keyHex, + contactName: contactsProvider.pendingAdvertByKey(publicKey)?.advName, ), ); } + if (contactsProvider.shouldEnrichPendingAdvert(publicKey)) { + unawaited(connectionProvider.previewContact(publicKey)); + } } }; @@ -1923,6 +2087,314 @@ class AppProvider with ChangeNotifier { ); } + Future _requestRepeaterStatus(Uint8List publicKey) async { + if (!connectionProvider.deviceInfo.isConnected) { + return; + } + final keyHex = publicKey + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + final now = DateTime.now(); + final lastRequestAt = _recentRepeaterStatusRequests[keyHex]; + if (lastRequestAt != null && + now.difference(lastRequestAt) < const Duration(minutes: 2)) { + return; + } + _recentRepeaterStatusRequests[keyHex] = now; + try { + await connectionProvider.requestStatus(publicKey); + } catch (_) { + // Ignore unsupported or unreachable repeaters. + } + } + + Future _maybeRequestRepeaterOwnerInfo(Uint8List publicKey) async { + final pendingAdvert = contactsProvider.pendingAdvertByKey(publicKey); + if (pendingAdvert == null || + pendingAdvert.typeValue != ContactType.repeater.value || + pendingAdvert.advName?.trim().isNotEmpty == true || + !connectionProvider.deviceInfo.isConnected) { + return; + } + + final keyHex = pendingAdvert.publicKeyHex; + final now = DateTime.now(); + final lastRequestAt = _recentRepeaterOwnerInfoRequests[keyHex]; + if (lastRequestAt != null && + now.difference(lastRequestAt) < _repeaterOwnerInfoRequestCooldown) { + return; + } + + Contact? existingContact; + for (final contact in contactsProvider.contacts) { + if (contact.publicKeyHex == keyHex) { + existingContact = contact; + break; + } + } + + var temporaryContactAdded = false; + final requestContact = + existingContact ?? _temporaryRepeaterContactForOwnerInfo(pendingAdvert); + if (requestContact == null || !requestContact.routeHasPath) { + return; + } + + _recentRepeaterOwnerInfoRequests[keyHex] = now; + + if (existingContact == null) { + connectionProvider.clearError(); + await connectionProvider.addOrUpdateContact(requestContact); + if (connectionProvider.error != null) { + return; + } + connectionProvider.clearError(); + temporaryContactAdded = true; + } + + try { + final ticket = await connectionProvider.sendAnonRequest( + contactPublicKey: publicKey, + requestData: _buildRepeaterOwnerRequest(requestContact), + ); + if (ticket == null) { + return; + } + _pendingRepeaterOwnerRequests[ticket.tag] = _PendingRepeaterOwnerRequest( + publicKey: Uint8List.fromList(publicKey), + ); + Future.delayed( + Duration(milliseconds: ticket.suggestedTimeoutMs + 1500), + () => _pendingRepeaterOwnerRequests.remove(ticket.tag), + ); + } catch (_) { + // Ignore unsupported or unreachable repeaters. + } finally { + if (temporaryContactAdded) { + Future.delayed(const Duration(milliseconds: 250), () async { + await connectionProvider.removeContact(publicKey); + connectionProvider.clearError(); + }); + } + } + } + + Contact? _temporaryRepeaterContactForOwnerInfo(PendingAdvert advert) { + final signedEncodedPathLen = advert.signedEncodedPathLen; + if (signedEncodedPathLen == null) { + return null; + } + + final advName = advert.advName?.trim(); + final lastAdvert = + advert.lastAdvert ?? (advert.receivedAt.millisecondsSinceEpoch ~/ 1000); + return Contact( + publicKey: Uint8List.fromList(advert.publicKey), + type: ContactType.repeater, + flags: advert.flags ?? 0, + outPathLen: signedEncodedPathLen, + outPath: advert.paddedPathBytes == null + ? Uint8List(ContactRouteCodec.maxPathBytes) + : Uint8List.fromList(advert.paddedPathBytes!), + advName: advName?.isNotEmpty == true ? advName! : advert.shortDisplayKey, + lastAdvert: lastAdvert, + advLat: advert.advLat ?? 0, + advLon: advert.advLon ?? 0, + lastMod: lastAdvert, + ); + } + + Uint8List _buildRepeaterOwnerRequest(Contact contact) { + final replyPathDescriptor = contact.routeEncodedPathLen; + final replyPathBytes = contact.routeHopCount > 0 + ? Uint8List.fromList( + LogRxRouteDecoder.reverseHopBytes( + contact.routePathBytes, + hashSize: contact.routeHashSize, + ), + ) + : Uint8List(0); + return Uint8List.fromList([ + _anonReqTypeOwner, + replyPathDescriptor, + ...replyPathBytes, + ]); + } + + bool _handlePendingRepeaterOwnerResponse(int tag, Uint8List responseData) { + final request = _pendingRepeaterOwnerRequests.remove(tag); + if (request == null) { + return false; + } + + final ownerName = _tryParseRepeaterOwnerName(responseData); + if (ownerName == null || ownerName.isEmpty) { + return true; + } + + final existing = contactsProvider.pendingAdvertByKey(request.publicKey); + contactsProvider.addOrUpdatePendingAdvertMetadata( + publicKey: request.publicKey, + typeValue: existing?.typeValue ?? ContactType.repeater.value, + devicePublicKey: connectionProvider.deviceInfo.publicKey, + advName: ownerName, + ); + return true; + } + + String? _tryParseRepeaterOwnerName(Uint8List responseData) { + if (responseData.length <= 4) { + return null; + } + + try { + final payload = utf8.decode( + responseData.sublist(4), + allowMalformed: true, + ).trim(); + if (payload.isEmpty) { + return null; + } + final firstLine = payload.split(RegExp(r'[\r\n]+')).first.trim(); + return firstLine.isEmpty ? null : firstLine; + } catch (_) { + return null; + } + } + + _ParsedRepeaterStatus? _tryParseRepeaterStatus(Uint8List statusData) { + if (statusData.length < 52) { + return null; + } + + try { + final data = ByteData.sublistView(statusData); + var offset = 0; + final batteryMv = data.getUint16(offset, Endian.little); + offset += 2; + final queueLen = data.getUint16(offset, Endian.little); + offset += 2; + offset += 2; // noiseFloor + final lastRssi = data.getInt16(offset, Endian.little); + offset += 2; + offset += 4; // packetsRecv + offset += 4; // packetsSent + offset += 4; // txAirSecs + final uptimeSecs = data.getUint32(offset, Endian.little); + offset += 4; + offset += 4; // floodTx + offset += 4; // directTx + offset += 4; // floodRx + offset += 4; // directRx + offset += 2; // errEvents + final lastSnrRaw = data.getInt16(offset, Endian.little); + + return _ParsedRepeaterStatus( + batteryMv: batteryMv, + queueLen: queueLen, + lastRssi: lastRssi, + lastSnrRaw: lastSnrRaw, + uptimeSecs: uptimeSecs, + ); + } catch (_) { + return null; + } + } + + _ParsedRawAdvert? _tryParseRawAdvert(Uint8List rawPayload) { + if (rawPayload.length < 103) { + return null; + } + + try { + final reader = BufferReader(rawPayload); + final header = reader.readByte(); + final routeType = header & 0x03; + final payloadType = (header >> 2) & 0x0F; + if (payloadType != _rawPayloadTypeAdvert) { + return null; + } + + if (routeType == _routeTransportFlood || + routeType == _routeTransportDirect) { + if (reader.remainingBytesCount < 4) { + return null; + } + reader.skip(4); + } + + if (reader.remainingBytesCount < 1) { + return null; + } + final pathByteLen = reader.readByte(); + if (reader.remainingBytesCount < pathByteLen + 101) { + return null; + } + final pathBytes = reader.readBytes(pathByteLen); + final publicKey = reader.readBytes(32); + final timestamp = reader.readInt32LE(); + reader.skip(64); + final flags = reader.readByte(); + final typeValue = flags & 0x0F; + final hasLocation = (flags & 0x10) != 0; + final hasName = (flags & 0x80) != 0; + + int? advLat; + int? advLon; + if (hasLocation) { + if (reader.remainingBytesCount < 8) { + return null; + } + advLat = reader.readInt32LE(); + advLon = reader.readInt32LE(); + } + + String? advName; + if (hasName && reader.remainingBytesCount > 0) { + final decodedName = utf8.decode( + reader.readRemainingBytes(), + allowMalformed: true, + ).trim(); + if (decodedName.isNotEmpty) { + advName = decodedName; + } + } + + int? signedEncodedPathLen; + Uint8List? paddedPathBytes; + if (pathBytes.isNotEmpty) { + final hashSize = LogRxRouteDecoder.inferHashSize(pathBytes); + final reversedPathBytes = LogRxRouteDecoder.reverseHopBytes( + pathBytes, + hashSize: hashSize, + ); + final padded = Uint8List(ContactRouteCodec.maxPathBytes) + ..setRange(0, reversedPathBytes.length, reversedPathBytes); + final encodedPathLen = + ((hashSize - 1) << 6) | + ((reversedPathBytes.length ~/ hashSize) & 0x3F); + signedEncodedPathLen = ContactRouteCodec.toSignedDescriptor( + encodedPathLen, + ); + paddedPathBytes = padded; + } + + return _ParsedRawAdvert( + publicKey: publicKey, + advName: advName, + typeValue: typeValue, + flags: flags, + lastAdvert: timestamp, + advLat: advLat, + advLon: advLon, + signedEncodedPathLen: signedEncodedPathLen, + paddedPathBytes: paddedPathBytes, + ); + } catch (_) { + return null; + } + } + int _inferReceivedPathHashSize( List pathBytes, { required int preferredHashSize, @@ -3142,6 +3614,68 @@ class AppProvider with ChangeNotifier { notifyListeners(); } + void _handleControlDataDiscovery({ + required Uint8List payload, + required int snrRaw, + required int rssiDbm, + required int pathLen, + }) { + const int controlTypeMask = 0xF0; + const int controlTypeNodeDiscoverResp = 0x90; + const int minFullDiscoverResponseLength = 6 + 32; + + if (payload.length < minFullDiscoverResponseLength) { + return; + } + + final controlType = payload[0] & controlTypeMask; + if (controlType != controlTypeNodeDiscoverResp) { + return; + } + + final nodeType = payload[0] & 0x0F; + final publicKey = Uint8List.fromList(payload.sublist(6, 38)); + final isNewPendingAdvert = contactsProvider + .addOrUpdatePendingAdvertMetadata( + publicKey: publicKey, + typeValue: nodeType, + devicePublicKey: connectionProvider.deviceInfo.publicKey, + rxRssiDbm: rssiDbm, + rxSnrRaw: snrRaw, + signedEncodedPathLen: pathLen == 0 ? 0 : null, + paddedPathBytes: pathLen == 0 + ? Uint8List(ContactRouteCodec.maxPathBytes) + : null, + ); + + debugPrint( + '🛰️ [AppProvider] Control discovery response: type=$nodeType ' + 'pathLen=$pathLen snr=$snrRaw rssi=$rssiDbm new=$isNewPendingAdvert', + ); + + if (isNewPendingAdvert) { + final keyHex = publicKey + .take(6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + unawaited( + _notificationService.showContactDiscoveredNotification( + contactKey: keyHex, + contactName: contactsProvider.pendingAdvertByKey(publicKey)?.advName, + ), + ); + } + + if (nodeType == ContactType.repeater.value) { + unawaited(_requestRepeaterStatus(publicKey)); + unawaited(_maybeRequestRepeaterOwnerInfo(publicKey)); + } + + if (contactsProvider.shouldEnrichPendingAdvert(publicKey)) { + unawaited(connectionProvider.previewContact(publicKey)); + } + } + /// Get app statistics Map get statistics { return { diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 4f8e70c..eae033c 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -1,5 +1,7 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; @@ -55,8 +57,21 @@ class ScannedDevice { ScannedDevice({required this.device, required this.rssi}); } +enum ContactReceiveSource { sync, requestedSingle, preview, advert } + +class _PendingContactRequest { + final ContactReceiveSource source; + final DateTime requestedAt; + + const _PendingContactRequest({ + required this.source, + required this.requestedAt, + }); +} + /// Connection Provider - manages MeshCore device connection (BLE or TCP/WiFi) class ConnectionProvider with ChangeNotifier { + static const int _controlTypeNodeDiscoverReq = 0x80; final MeshCoreBleService _bleService = MeshCoreBleService(); final SseServerService _sseServer = SseServerService(); MeshCoreTcpService? _tcpService; @@ -145,6 +160,13 @@ class ConnectionProvider with ChangeNotifier { bool _isAdvertInProgress = false; DateTime? _lastAdvertRequestedAt; static const Duration _minAdvertInterval = Duration(milliseconds: 500); + bool _isContactsSyncInProgress = false; + final Map _pendingSingleContactRequests = {}; + final Map _previewContactMisses = {}; + static const Duration _singleContactRequestWindow = Duration(seconds: 10); + static const Duration _previewContactMissTtl = Duration(minutes: 10); + bool _suppressNextPreviewNotFoundError = false; + bool? _supportsAutoaddConfig; // Helper instances final RoomLoginManager _roomLoginManager = RoomLoginManager(); @@ -162,6 +184,7 @@ class ConnectionProvider with ChangeNotifier { // Callbacks for other providers Function(Contact)? onContactReceived; + Function(Contact, ContactReceiveSource)? onContactReceivedDetailed; Function(List)? onContactsComplete; Function(Message)? onMessageReceived; Function(Uint8List publicKey, Uint8List lppData)? onTelemetryReceived; @@ -189,6 +212,8 @@ class ConnectionProvider with ChangeNotifier { onMessageEchoDetected; Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse; Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived; + Function(Uint8List payload, int snrRaw, int rssiDbm, int pathLen)? + onControlDataReceived; Contact? Function(Uint8List contactPublicKey)? resolveContactForDmCallback; bool Function()? canStartAutomaticMessageSyncCallback; @@ -241,6 +266,10 @@ class ConnectionProvider with ChangeNotifier { }; service.onError = (error, {int? errorCode}) { + if (errorCode == 2 && _suppressNextPreviewNotFoundError) { + _suppressNextPreviewNotFoundError = false; + return; + } debugPrint('⚠️ [Provider] Error received: $error'); _error = error; if (_deviceInfo.connectionState != ConnectionState.connected) { @@ -252,9 +281,21 @@ class ConnectionProvider with ChangeNotifier { }; service.onContactNotFound = (contactPublicKey) async { - debugPrint('🔧 [Provider] Contact not found - initiating auto-recovery'); if (contactPublicKey == null) return; + final keyHex = _publicKeyToHex(contactPublicKey); + final request = _pendingSingleContactRequests.remove(keyHex); + if (request?.source == ContactReceiveSource.preview) { + _previewContactMisses[keyHex] = DateTime.now(); + _suppressNextPreviewNotFoundError = true; + debugPrint( + '🔧 [Provider] Preview contact not found, suppressing retries for $keyHex', + ); + return; + } + + debugPrint('🔧 [Provider] Contact not found - initiating auto-recovery'); + final operationId = contactPublicKey .sublist(0, 6) .map((b) => b.toRadixString(16).padLeft(2, '0')) @@ -288,11 +329,14 @@ class ConnectionProvider with ChangeNotifier { service.onContactReceived = (contact) { debugPrint('📥 [Provider] Contact received: "${contact.advName}"'); + final source = _classifyContactReceiveSource(contact.publicKey); + onContactReceivedDetailed?.call(contact, source); onContactReceived?.call(contact); }; service.onContactsComplete = (contacts) { debugPrint('📥 [Provider] Contacts sync complete: ${contacts.length}'); + _isContactsSyncInProgress = false; if (_contactsSyncCompleter != null && !_contactsSyncCompleter!.isCompleted) { _contactsSyncCompleter!.complete(); @@ -408,6 +452,8 @@ class ConnectionProvider with ChangeNotifier { service.onRawDataReceived = (payload, snrRaw, rssiDbm) => onRawDataReceived?.call(payload, snrRaw, rssiDbm); + service.onControlDataReceived = (payload, snrRaw, rssiDbm, pathLen) => + onControlDataReceived?.call(payload, snrRaw, rssiDbm, pathLen); service.onDeviceInfoReceived = (deviceInfo) { debugPrint('📥 [Provider] DeviceInfo received'); @@ -481,6 +527,17 @@ class ConnectionProvider with ChangeNotifier { notifyListeners(); }; + service.onAutoaddConfigReceived = (config) { + _deviceInfo = _deviceInfo.copyWith( + autoAddUsers: config['autoAddUsers'] as bool?, + autoAddRepeaters: config['autoAddRepeaters'] as bool?, + autoAddRoomServers: config['autoAddRoomServers'] as bool?, + autoAddSensors: config['autoAddSensors'] as bool?, + autoAddOverwriteOldest: config['autoAddOverwriteOldest'] as bool?, + ); + notifyListeners(); + }; + service.onTxActivity = () { _txActivity = true; notifyListeners(); @@ -600,6 +657,7 @@ class ConnectionProvider with ChangeNotifier { connectionState: ConnectionState.connecting, ); _error = null; + _supportsAutoaddConfig = null; _resetSyncState(); debugPrint('✅ [Provider] Device info updated to connecting state'); notifyListeners(); @@ -630,6 +688,7 @@ class ConnectionProvider with ChangeNotifier { connectionState: ConnectionState.connecting, ); _error = null; + _supportsAutoaddConfig = null; notifyListeners(); // Create fresh TCP service and wire its callbacks @@ -658,6 +717,7 @@ class ConnectionProvider with ChangeNotifier { } _tcpHost = null; _connectionMode = ConnectionMode.ble; + _supportsAutoaddConfig = null; _resetSyncState(); _deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected); _roomLoginManager.clearRoomLoginStates(); @@ -681,6 +741,7 @@ class ConnectionProvider with ChangeNotifier { await _bleService.disconnect(); + _supportsAutoaddConfig = null; _resetSyncState(); _deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected); _roomLoginManager.clearRoomLoginStates(); @@ -761,6 +822,7 @@ class ConnectionProvider with ChangeNotifier { } try { + _isContactsSyncInProgress = true; _contactsSyncCompleter = Completer(); await _activeService.getContacts(); await _contactsSyncCompleter!.future.timeout( @@ -772,9 +834,11 @@ class ConnectionProvider with ChangeNotifier { }, ); } catch (e) { + _isContactsSyncInProgress = false; _error = 'Failed to get contacts: $e'; notifyListeners(); } finally { + _isContactsSyncInProgress = false; _contactsSyncCompleter = null; } } @@ -793,6 +857,10 @@ class ConnectionProvider with ChangeNotifier { } try { + _markSingleContactRequested( + publicKey, + source: ContactReceiveSource.requestedSingle, + ); await _activeService.getContactByKey(publicKey); } catch (e) { _error = 'Failed to get contact: $e'; @@ -800,11 +868,81 @@ class ConnectionProvider with ChangeNotifier { '⚠️ [Provider] Failed to get contact by key, falling back to full contact sync', ); // Fallback to full contact sync if command not supported + _isContactsSyncInProgress = true; await _activeService.getContacts(); notifyListeners(); } } + Future previewContact(Uint8List publicKey) async { + if (!_activeService.isConnected) { + return; + } + _prunePreviewContactMisses(); + final keyHex = _publicKeyToHex(publicKey); + if (_previewContactMisses.containsKey(keyHex)) { + return; + } + + try { + _markSingleContactRequested( + publicKey, + source: ContactReceiveSource.preview, + ); + await _activeService.getContactByKey(publicKey); + } catch (e) { + debugPrint( + '⚠️ [Provider] Preview contact fetch failed for ${_publicKeyToHex(publicKey)}: $e', + ); + } + } + + ContactReceiveSource _classifyContactReceiveSource(Uint8List publicKey) { + _prunePendingSingleContactRequests(); + final keyHex = _publicKeyToHex(publicKey); + if (_isContactsSyncInProgress) { + return ContactReceiveSource.sync; + } + final request = _pendingSingleContactRequests.remove(keyHex); + if (request != null && + DateTime.now().difference(request.requestedAt) <= + _singleContactRequestWindow) { + return request.source; + } + return ContactReceiveSource.advert; + } + + void _markSingleContactRequested( + Uint8List publicKey, { + required ContactReceiveSource source, + }) { + _prunePendingSingleContactRequests(); + _pendingSingleContactRequests[_publicKeyToHex(publicKey)] = + _PendingContactRequest(source: source, requestedAt: DateTime.now()); + } + + void _prunePendingSingleContactRequests() { + if (_pendingSingleContactRequests.isEmpty) { + return; + } + + final now = DateTime.now(); + _pendingSingleContactRequests.removeWhere( + (_, request) => + now.difference(request.requestedAt) > _singleContactRequestWindow, + ); + } + + void _prunePreviewContactMisses() { + if (_previewContactMisses.isEmpty) { + return; + } + final now = DateTime.now(); + _previewContactMisses.removeWhere( + (_, timestamp) => now.difference(timestamp) > _previewContactMissTtl, + ); + } + /// Sync all channels from device Future syncChannels({int? maxChannels}) async { if (!_activeService.isConnected) { @@ -1656,6 +1794,40 @@ class ConnectionProvider with ChangeNotifier { } } + Future discoverNodeType({ + required int advertType, + bool prefixOnly = false, + int since = 0, + }) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + final random = Random.secure(); + final tagBytes = Uint8List.fromList( + List.generate(4, (_) => random.nextInt(256)), + ); + final payload = BytesBuilder(copy: false) + ..addByte(_controlTypeNodeDiscoverReq | (prefixOnly ? 0x01 : 0x00)) + ..addByte(1 << advertType) + ..add(tagBytes) + ..add([ + since & 0xFF, + (since >> 8) & 0xFF, + (since >> 16) & 0xFF, + (since >> 24) & 0xFF, + ]); + + try { + await _activeService.sendControlData(payload.toBytes()); + } catch (e) { + _error = 'Failed to send node discovery request: $e'; + notifyListeners(); + } + } + /// Get device time from companion radio to detect clock drift Future getDeviceTime() async { if (!_activeService.isConnected) { @@ -1892,6 +2064,80 @@ class ConnectionProvider with ChangeNotifier { } } + Future getAutoaddConfig() async { + if (_supportsAutoaddConfig == false) { + return; + } + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + final config = await _activeService.getAutoaddConfig(); + _supportsAutoaddConfig = true; + _deviceInfo = _deviceInfo.copyWith( + autoAddUsers: config['autoAddUsers'] as bool?, + autoAddRepeaters: config['autoAddRepeaters'] as bool?, + autoAddRoomServers: config['autoAddRoomServers'] as bool?, + autoAddSensors: config['autoAddSensors'] as bool?, + autoAddOverwriteOldest: config['autoAddOverwriteOldest'] as bool?, + ); + notifyListeners(); + } catch (e) { + if (_isUnsupportedAutoaddConfigError(e)) { + _supportsAutoaddConfig = false; + _deviceInfo = _deviceInfo.copyWith( + autoAddUsers: null, + autoAddRepeaters: null, + autoAddRoomServers: null, + autoAddSensors: null, + autoAddOverwriteOldest: null, + ); + notifyListeners(); + return; + } + _error = 'Failed to get auto-add config: $e'; + notifyListeners(); + } + } + + Future setAutoaddConfig({ + required bool autoAddUsers, + required bool autoAddRepeaters, + required bool autoAddRoomServers, + required bool autoAddSensors, + required bool overwriteOldest, + }) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + await _activeService.setAutoaddConfig( + autoAddUsers: autoAddUsers, + autoAddRepeaters: autoAddRepeaters, + autoAddRoomServers: autoAddRoomServers, + autoAddSensors: autoAddSensors, + overwriteOldest: overwriteOldest, + ); + _deviceInfo = _deviceInfo.copyWith( + autoAddUsers: autoAddUsers, + autoAddRepeaters: autoAddRepeaters, + autoAddRoomServers: autoAddRoomServers, + autoAddSensors: autoAddSensors, + autoAddOverwriteOldest: overwriteOldest, + ); + notifyListeners(); + } catch (e) { + _error = 'Failed to set auto-add config: $e'; + notifyListeners(); + } + } + /// Request fresh device info (triggers SelfInfo response) Future refreshDeviceInfo() async { if (_isSpectrumScanActive) return; @@ -1904,14 +2150,35 @@ class ConnectionProvider with ChangeNotifier { try { // The device query command triggers a SelfInfo response await _activeService.refreshDeviceInfo(); - // Also request allowed repeat frequencies (firmware v9+, no-op on older firmware) - await _activeService.getAllowedRepeatFreq(); + if (_supportsAutoaddConfig != false) { + try { + await _activeService.getAutoaddConfig(); + _supportsAutoaddConfig = true; + } catch (e) { + if (_isUnsupportedAutoaddConfigError(e)) { + _supportsAutoaddConfig = false; + } else { + rethrow; + } + } + } + try { + await _activeService.getAllowedRepeatFreq(); + } catch (_) { + // Older firmware may not expose repeat frequency ranges. + } } catch (e) { _error = 'Failed to refresh device info: $e'; notifyListeners(); } } + bool _isUnsupportedAutoaddConfigError(Object error) { + final message = error.toString().toLowerCase(); + return message.contains('illegal argument') || + message.contains('unsupported'); + } + /// Request battery and storage information /// /// Queries the companion radio for: @@ -2195,6 +2462,28 @@ class ConnectionProvider with ChangeNotifier { } } + Future<({int tag, int suggestedTimeoutMs})?> sendAnonRequest({ + required Uint8List contactPublicKey, + required Uint8List requestData, + }) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return null; + } + + try { + return await _activeService.sendAnonRequest( + contactPublicKey: contactPublicKey, + requestData: requestData, + ); + } catch (e) { + _error = 'Failed to send anonymous request: $e'; + notifyListeners(); + return null; + } + } + /// Reset routing path for a contact /// /// Clears the learned path to a contact, forcing the next message to use diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index 0d81377..c48bbd4 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -13,14 +13,40 @@ import '../utils/key_comparison.dart'; class PendingAdvert { final Uint8List publicKey; final DateTime receivedAt; + final String? advName; + final int? typeValue; + final int? flags; + final int? lastAdvert; + final int? advLat; + final int? advLon; final int? signedEncodedPathLen; final Uint8List? paddedPathBytes; + final int? rxRssiDbm; + final int? rxSnrRaw; + final int? repeaterBatteryMv; + final int? repeaterQueueLen; + final int? repeaterLastRssi; + final int? repeaterLastSnrRaw; + final int? repeaterUptimeSecs; const PendingAdvert({ required this.publicKey, required this.receivedAt, + this.advName, + this.typeValue, + this.flags, + this.lastAdvert, + this.advLat, + this.advLon, this.signedEncodedPathLen, this.paddedPathBytes, + this.rxRssiDbm, + this.rxSnrRaw, + this.repeaterBatteryMv, + this.repeaterQueueLen, + this.repeaterLastRssi, + this.repeaterLastSnrRaw, + this.repeaterUptimeSecs, }); String get publicKeyHex => @@ -34,16 +60,55 @@ class PendingAdvert { PendingAdvert copyWith({ Uint8List? publicKey, DateTime? receivedAt, + String? advName, + int? typeValue, + int? flags, + int? lastAdvert, + int? advLat, + int? advLon, int? signedEncodedPathLen, Uint8List? paddedPathBytes, + int? rxRssiDbm, + int? rxSnrRaw, + int? repeaterBatteryMv, + int? repeaterQueueLen, + int? repeaterLastRssi, + int? repeaterLastSnrRaw, + int? repeaterUptimeSecs, }) { return PendingAdvert( publicKey: publicKey ?? this.publicKey, receivedAt: receivedAt ?? this.receivedAt, + advName: advName ?? this.advName, + typeValue: typeValue ?? this.typeValue, + flags: flags ?? this.flags, + lastAdvert: lastAdvert ?? this.lastAdvert, + advLat: advLat ?? this.advLat, + advLon: advLon ?? this.advLon, signedEncodedPathLen: signedEncodedPathLen ?? this.signedEncodedPathLen, paddedPathBytes: paddedPathBytes ?? this.paddedPathBytes, + rxRssiDbm: rxRssiDbm ?? this.rxRssiDbm, + rxSnrRaw: rxSnrRaw ?? this.rxSnrRaw, + repeaterBatteryMv: repeaterBatteryMv ?? this.repeaterBatteryMv, + repeaterQueueLen: repeaterQueueLen ?? this.repeaterQueueLen, + repeaterLastRssi: repeaterLastRssi ?? this.repeaterLastRssi, + repeaterLastSnrRaw: repeaterLastSnrRaw ?? this.repeaterLastSnrRaw, + repeaterUptimeSecs: repeaterUptimeSecs ?? this.repeaterUptimeSecs, ); } + + double? get repeaterBatteryPercent { + if (repeaterBatteryMv == null) return null; + final voltage = repeaterBatteryMv! / 1000.0; + if (voltage <= 3.0) return 0.0; + if (voltage >= 4.2) return 100.0; + return ((voltage - 3.0) / 1.2) * 100.0; + } + + double? get repeaterLastSnr => + repeaterLastSnrRaw == null ? null : repeaterLastSnrRaw! / 4.0; + + double? get rxSnr => rxSnrRaw == null ? null : rxSnrRaw! / 4.0; } class _RetainedRoute { @@ -257,6 +322,28 @@ class ContactsProvider with ChangeNotifier { _pendingAdverts.values.toList() ..sort((a, b) => b.receivedAt.compareTo(a.receivedAt)); + PendingAdvert? pendingAdvertByKey(Uint8List publicKey) { + final keyHex = publicKey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + return _pendingAdverts[keyHex]; + } + + bool shouldEnrichPendingAdvert(Uint8List publicKey) { + final advert = pendingAdvertByKey(publicKey); + if (advert == null) { + return false; + } + + final hasName = advert.advName?.trim().isNotEmpty ?? false; + final hasType = advert.typeValue != null && advert.typeValue != 0; + final hasLocation = + advert.advLat != null && + advert.advLon != null && + (advert.advLat != 0 || advert.advLon != 0); + return !(hasName && hasType && hasLocation); + } + List savedGroupsForSection(String sectionKey) { return savedContactGroups .where((group) => group.sectionKey == sectionKey) @@ -466,7 +553,6 @@ class ContactsProvider with ChangeNotifier { ); _contacts[contact.publicKeyHex] = updatedContact; - _pendingAdverts.remove(contact.publicKeyHex); debugPrint( ' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}', ); @@ -495,7 +581,6 @@ class ContactsProvider with ChangeNotifier { incomingContact: contact, existingContact: existingContact, ); - _pendingAdverts.remove(contact.publicKeyHex); } if (excluded > 0) { debugPrint( @@ -1095,7 +1180,7 @@ class ContactsProvider with ChangeNotifier { } /// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80). - /// Excludes self key and existing contacts. + /// Excludes only self key; known contacts still keep a discovery entry. bool addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) { if (devicePublicKey != null && publicKey.matches(devicePublicKey)) { return false; @@ -1104,12 +1189,6 @@ class ContactsProvider with ChangeNotifier { final keyHex = publicKey .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(''); - if (_contacts.containsKey(keyHex)) { - _pendingAdverts.remove(keyHex); - _persistPendingAdverts(); - return false; - } - final existing = _pendingAdverts[keyHex]; final now = DateTime.now(); if (existing != null) { @@ -1128,6 +1207,99 @@ class ContactsProvider with ChangeNotifier { } } + bool addOrUpdatePendingAdvertContact( + Contact contact, { + Uint8List? devicePublicKey, + }) { + if (devicePublicKey != null && contact.publicKey.matches(devicePublicKey)) { + return false; + } + + final keyHex = contact.publicKeyHex; + final route = ContactRouteCodec.fromContact(contact); + final existing = _pendingAdverts[keyHex]; + final now = DateTime.now(); + final updated = + (existing ?? + PendingAdvert( + publicKey: Uint8List.fromList(contact.publicKey), + receivedAt: now, + )) + .copyWith( + receivedAt: now, + advName: contact.advName.trim().isEmpty ? null : contact.advName, + typeValue: contact.type.value, + flags: contact.flags, + lastAdvert: contact.lastAdvert, + advLat: contact.advLat, + advLon: contact.advLon, + signedEncodedPathLen: + route?.signedEncodedPathLen ?? existing?.signedEncodedPathLen, + paddedPathBytes: route?.paddedPathBytes == null + ? existing?.paddedPathBytes + : Uint8List.fromList(route!.paddedPathBytes), + ); + + _pendingAdverts[keyHex] = updated; + _persistPendingAdverts(); + notifyListeners(); + return existing == null; + } + + bool addOrUpdatePendingAdvertMetadata({ + required Uint8List publicKey, + required int typeValue, + Uint8List? devicePublicKey, + int? flags, + String? advName, + int? lastAdvert, + int? advLat, + int? advLon, + int? signedEncodedPathLen, + Uint8List? paddedPathBytes, + int? rxRssiDbm, + int? rxSnrRaw, + DateTime? receivedAt, + }) { + if (devicePublicKey != null && publicKey.matches(devicePublicKey)) { + return false; + } + + final keyHex = publicKey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + final existing = _pendingAdverts[keyHex]; + final nextReceivedAt = receivedAt ?? DateTime.now(); + _pendingAdverts[keyHex] = + (existing ?? + PendingAdvert( + publicKey: Uint8List.fromList(publicKey), + receivedAt: nextReceivedAt, + )) + .copyWith( + receivedAt: nextReceivedAt, + typeValue: typeValue, + advName: advName?.trim().isNotEmpty == true + ? advName!.trim() + : existing?.advName, + flags: flags ?? existing?.flags, + lastAdvert: lastAdvert ?? existing?.lastAdvert, + advLat: advLat ?? existing?.advLat, + advLon: advLon ?? existing?.advLon, + signedEncodedPathLen: + signedEncodedPathLen ?? existing?.signedEncodedPathLen, + paddedPathBytes: paddedPathBytes == null + ? existing?.paddedPathBytes + : Uint8List.fromList(paddedPathBytes), + rxRssiDbm: rxRssiDbm ?? existing?.rxRssiDbm, + rxSnrRaw: rxSnrRaw ?? existing?.rxSnrRaw, + ); + + _persistPendingAdverts(); + notifyListeners(); + return existing == null; + } + /// Find contact by name Contact? findContactByName(String name) { for (final contact in _contacts.values) { @@ -1220,6 +1392,45 @@ class ContactsProvider with ChangeNotifier { await _storageService.saveContacts(_contactsForStorage()); } + Future clearPendingAdverts() async { + if (_pendingAdverts.isEmpty) { + return; + } + _pendingAdverts.clear(); + await _storageService.clearPendingAdverts(); + notifyListeners(); + } + + void updatePendingAdvertStatusByPrefix( + Uint8List publicKeyPrefix, { + int? batteryMv, + int? queueLen, + int? lastRssi, + int? lastSnrRaw, + int? uptimeSecs, + }) { + final prefixHex = publicKeyPrefix + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + final match = _pendingAdverts.entries.where( + (entry) => entry.key.startsWith(prefixHex), + ); + if (match.isEmpty) { + return; + } + final key = match.first.key; + final existing = _pendingAdverts[key]!; + _pendingAdverts[key] = existing.copyWith( + repeaterBatteryMv: batteryMv, + repeaterQueueLen: queueLen, + repeaterLastRssi: lastRssi, + repeaterLastSnrRaw: lastSnrRaw, + repeaterUptimeSecs: uptimeSecs, + ); + _persistPendingAdverts(); + notifyListeners(); + } + List _contactsForStorage() { // Don't persist the public channel pseudo-contact (all zeros key) const publicChannelKey = @@ -1253,10 +1464,23 @@ class ContactsProvider with ChangeNotifier { return { 'publicKey': base64Encode(advert.publicKey), 'receivedAtMillis': advert.receivedAt.millisecondsSinceEpoch, + 'advName': advert.advName, + 'typeValue': advert.typeValue, + 'flags': advert.flags, + 'lastAdvert': advert.lastAdvert, + 'advLat': advert.advLat, + 'advLon': advert.advLon, 'signedEncodedPathLen': advert.signedEncodedPathLen, 'paddedPathBytes': advert.paddedPathBytes == null ? null : base64Encode(advert.paddedPathBytes!), + 'rxRssiDbm': advert.rxRssiDbm, + 'rxSnrRaw': advert.rxSnrRaw, + 'repeaterBatteryMv': advert.repeaterBatteryMv, + 'repeaterQueueLen': advert.repeaterQueueLen, + 'repeaterLastRssi': advert.repeaterLastRssi, + 'repeaterLastSnrRaw': advert.repeaterLastSnrRaw, + 'repeaterUptimeSecs': advert.repeaterUptimeSecs, }; } @@ -1269,12 +1493,25 @@ class ContactsProvider with ChangeNotifier { receivedAt: DateTime.fromMillisecondsSinceEpoch( json['receivedAtMillis'] as int, ), + advName: json['advName'] as String?, + typeValue: json['typeValue'] as int?, + flags: json['flags'] as int?, + lastAdvert: json['lastAdvert'] as int?, + advLat: json['advLat'] as int?, + advLon: json['advLon'] as int?, signedEncodedPathLen: json['signedEncodedPathLen'] as int?, paddedPathBytes: json['paddedPathBytes'] == null ? null : Uint8List.fromList( base64Decode(json['paddedPathBytes'] as String), ), + rxRssiDbm: json['rxRssiDbm'] as int?, + rxSnrRaw: json['rxSnrRaw'] as int?, + repeaterBatteryMv: json['repeaterBatteryMv'] as int?, + repeaterQueueLen: json['repeaterQueueLen'] as int?, + repeaterLastRssi: json['repeaterLastRssi'] as int?, + repeaterLastSnrRaw: json['repeaterLastSnrRaw'] as int?, + repeaterUptimeSecs: json['repeaterUptimeSecs'] as int?, ); } catch (e) { debugPrint( diff --git a/lib/screens/device_config_screen.dart b/lib/screens/device_config_screen.dart index 6e57c6a..d166eb5 100644 --- a/lib/screens/device_config_screen.dart +++ b/lib/screens/device_config_screen.dart @@ -180,15 +180,23 @@ class _DeviceConfigScreenState extends State { bool _telemetryEnabled = false; bool _repeatEnabled = false; bool _autoAddDiscoveredContactsEnabled = true; + bool _autoAddUsersEnabled = true; + bool _autoAddRepeatersEnabled = true; + bool _autoAddRoomServersEnabled = true; + bool _autoAddSensorsEnabled = true; + bool _overwriteOldestAutoAddEnabled = false; bool _showCustomRadioSettings = false; bool _isSavingPublicInfo = false; bool _isSavingRadioSettings = false; + bool _isSavingAutoDiscoverySettings = false; bool _isClearingContacts = false; bool _isClearingChannels = false; bool _publicInfoSaved = false; bool _radioSettingsSaved = false; + bool _autoDiscoverySettingsSaved = false; String? _publicInfoError; String? _radioSettingsError; + String? _autoDiscoverySettingsError; String _selectedBandwidth = '62.5 kHz'; int _selectedSpreadingFactor = 8; int _selectedCodingRate = 8; @@ -267,6 +275,12 @@ class _DeviceConfigScreenState extends State { _repeatEnabled = deviceInfo.clientRepeat ?? false; _autoAddDiscoveredContactsEnabled = !(deviceInfo.manualAddContacts ?? false); + _autoAddUsersEnabled = deviceInfo.autoAddUsers ?? true; + _autoAddRepeatersEnabled = deviceInfo.autoAddRepeaters ?? true; + _autoAddRoomServersEnabled = deviceInfo.autoAddRoomServers ?? true; + _autoAddSensorsEnabled = deviceInfo.autoAddSensors ?? true; + _overwriteOldestAutoAddEnabled = + deviceInfo.autoAddOverwriteOldest ?? false; // Fetch allowed repeat frequencies on open if device supports repeat mode if (deviceInfo.clientRepeat != null && @@ -278,6 +292,7 @@ class _DeviceConfigScreenState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { context.read().getBatteryAndStorage(); + context.read().getAutoaddConfig(); }); } @@ -390,6 +405,15 @@ class _DeviceConfigScreenState extends State { } } + void _markAutoDiscoverySettingsDirty() { + if (_autoDiscoverySettingsSaved || _autoDiscoverySettingsError != null) { + setState(() { + _autoDiscoverySettingsSaved = false; + _autoDiscoverySettingsError = null; + }); + } + } + Future _savePublicInfo() async { final connectionProvider = context.read(); final validator = ValidationService(); @@ -401,7 +425,8 @@ class _DeviceConfigScreenState extends State { }); try { - final manualAddContacts = _autoAddDiscoveredContactsEnabled ? 0 : 1; + final manualAddContacts = + (connectionProvider.deviceInfo.manualAddContacts ?? false) ? 1 : 0; // Save name if (_nameController.text.isNotEmpty) { @@ -553,6 +578,49 @@ class _DeviceConfigScreenState extends State { } } + Future _saveAutoDiscoverySettings() async { + final connectionProvider = context.read(); + + setState(() { + _isSavingAutoDiscoverySettings = true; + _autoDiscoverySettingsSaved = false; + _autoDiscoverySettingsError = null; + }); + + try { + await connectionProvider.setOtherParams( + manualAddContacts: _autoAddDiscoveredContactsEnabled ? 0 : 1, + telemetryModes: connectionProvider.deviceInfo.telemetryModes ?? 0, + advertLocationPolicy: connectionProvider.deviceInfo.advertLocPolicy ?? 0, + multiAcks: connectionProvider.deviceInfo.multiAcks ?? 0, + ); + await connectionProvider.setAutoaddConfig( + autoAddUsers: _autoAddUsersEnabled, + autoAddRepeaters: _autoAddRepeatersEnabled, + autoAddRoomServers: _autoAddRoomServersEnabled, + autoAddSensors: _autoAddSensorsEnabled, + overwriteOldest: _overwriteOldestAutoAddEnabled, + ); + await connectionProvider.refreshDeviceInfo(); + + if (mounted) { + setState(() { + _isSavingAutoDiscoverySettings = false; + _autoDiscoverySettingsSaved = true; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isSavingAutoDiscoverySettings = false; + _autoDiscoverySettingsError = AppLocalizations.of( + context, + )!.failedToSave(e.toString()); + }); + } + } + } + Future _useCurrentLocation() async { try { bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); @@ -997,9 +1065,10 @@ class _DeviceConfigScreenState extends State { ), const SizedBox(height: 20), _ConfigSectionCard( - title: AppLocalizations.of(context)!.publicInfo, - subtitle: 'Choose the name and location this device shares.', - icon: Icons.public_rounded, + title: 'Auto discovery', + subtitle: + 'Control how the radio auto-adds discovered nodes to its contacts table.', + icon: Icons.person_search_rounded, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -1007,9 +1076,9 @@ class _DeviceConfigScreenState extends State { icon: _autoAddDiscoveredContactsEnabled ? Icons.person_add_alt_1 : Icons.person_add_disabled, - title: 'Auto-add discovered contacts', + title: 'Enable automatic adding', description: - 'Control whether the device automatically stores newly discovered contacts.', + 'Turn this off to keep discoveries manual-only on the radio.', accentColor: _autoAddDiscoveredContactsEnabled ? colorScheme.primary : colorScheme.onSurfaceVariant, @@ -1018,13 +1087,148 @@ class _DeviceConfigScreenState extends State { onChanged: (value) { setState(() { _autoAddDiscoveredContactsEnabled = value; - _publicInfoSaved = false; - _publicInfoError = null; + _markAutoDiscoverySettingsDirty(); }); }, ), ), const SizedBox(height: 18), + _SettingHighlightCard( + icon: Icons.person_outline_rounded, + title: 'Auto-add users', + description: + 'Automatically store discovered user/chat nodes.', + accentColor: _autoAddUsersEnabled + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + trailing: Switch( + value: _autoAddUsersEnabled, + onChanged: _autoAddDiscoveredContactsEnabled + ? (value) { + setState(() { + _autoAddUsersEnabled = value; + _markAutoDiscoverySettingsDirty(); + }); + } + : null, + ), + ), + const SizedBox(height: 14), + _SettingHighlightCard( + icon: Icons.router_outlined, + title: 'Auto-add repeaters', + description: + 'Automatically store discovered repeater nodes.', + accentColor: _autoAddRepeatersEnabled + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + trailing: Switch( + value: _autoAddRepeatersEnabled, + onChanged: _autoAddDiscoveredContactsEnabled + ? (value) { + setState(() { + _autoAddRepeatersEnabled = value; + _markAutoDiscoverySettingsDirty(); + }); + } + : null, + ), + ), + const SizedBox(height: 14), + _SettingHighlightCard( + icon: Icons.meeting_room_outlined, + title: 'Auto-add room servers', + description: + 'Automatically store discovered room/server nodes.', + accentColor: _autoAddRoomServersEnabled + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + trailing: Switch( + value: _autoAddRoomServersEnabled, + onChanged: _autoAddDiscoveredContactsEnabled + ? (value) { + setState(() { + _autoAddRoomServersEnabled = value; + _markAutoDiscoverySettingsDirty(); + }); + } + : null, + ), + ), + const SizedBox(height: 14), + _SettingHighlightCard( + icon: Icons.sensors_outlined, + title: 'Auto-add sensors', + description: + 'Automatically store discovered sensor nodes.', + accentColor: _autoAddSensorsEnabled + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + trailing: Switch( + value: _autoAddSensorsEnabled, + onChanged: _autoAddDiscoveredContactsEnabled + ? (value) { + setState(() { + _autoAddSensorsEnabled = value; + _markAutoDiscoverySettingsDirty(); + }); + } + : null, + ), + ), + const SizedBox(height: 14), + _SettingHighlightCard( + icon: Icons.history_toggle_off_rounded, + title: 'Overwrite oldest when full', + description: + 'Allow the radio to replace the oldest contact when storage is full.', + accentColor: _overwriteOldestAutoAddEnabled + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + trailing: Switch( + value: _overwriteOldestAutoAddEnabled, + onChanged: _autoAddDiscoveredContactsEnabled + ? (value) { + setState(() { + _overwriteOldestAutoAddEnabled = value; + _markAutoDiscoverySettingsDirty(); + }); + } + : null, + ), + ), + const SizedBox(height: 18), + if (_autoDiscoverySettingsError != null) ...[ + Text( + _autoDiscoverySettingsError!, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.error, + ), + ), + const SizedBox(height: 10), + ], + SizedBox( + width: double.infinity, + child: _SaveActionButton( + onPressed: _isSavingAutoDiscoverySettings + ? null + : _saveAutoDiscoverySettings, + isSaving: _isSavingAutoDiscoverySettings, + isSaved: _autoDiscoverySettingsSaved, + label: 'Save discovery settings', + ), + ), + ], + ), + ), + const SizedBox(height: 20), + _ConfigSectionCard( + title: AppLocalizations.of(context)!.publicInfo, + subtitle: 'Choose the name and location this device shares.', + icon: Icons.public_rounded, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ _SettingHighlightCard( icon: _telemetryEnabled ? Icons.travel_explore diff --git a/lib/screens/discovery_screen.dart b/lib/screens/discovery_screen.dart index fc44f99..d6cda68 100644 --- a/lib/screens/discovery_screen.dart +++ b/lib/screens/discovery_screen.dart @@ -1,11 +1,15 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'dart:typed_data'; 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'; +import '../widgets/compact_signal_indicator.dart' show SignalMetric; + +enum _DiscoveryMenuAction { repeaters, sensors } class DiscoveryScreen extends StatefulWidget { const DiscoveryScreen({super.key}); @@ -15,7 +19,10 @@ class DiscoveryScreen extends StatefulWidget { } class _DiscoveryScreenState extends State { + static const int _repeaterAdvertType = 2; + static const int _sensorAdvertType = 4; final Set _resolvingAdvertKeys = {}; + final Set _runningDiscoveryTypes = {}; bool _isResolvingAll = false; late final Future> _cachedNodesFuture; @@ -27,6 +34,85 @@ class _DiscoveryScreenState extends State { ); } + Future _handleMenuAction(_DiscoveryMenuAction action) async { + switch (action) { + case _DiscoveryMenuAction.repeaters: + await _discoverNodeType(_repeaterAdvertType); + break; + case _DiscoveryMenuAction.sensors: + await _discoverNodeType(_sensorAdvertType); + break; + } + } + + Future _clearAllDiscoveries() async { + final pendingCount = context.read().pendingAdverts.length; + if (pendingCount == 0) { + return; + } + + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Clear discoveries'), + content: Text( + 'Remove all $pendingCount pending discover${pendingCount == 1 ? 'y' : 'ies'} from this device?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('Clear all'), + ), + ], + ), + ); + + if (confirmed != true || !mounted) { + return; + } + + await context.read().clearPendingAdverts(); + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Cleared pending discoveries.')), + ); + } + + Future _discoverNodeType(int advertType) async { + if (_runningDiscoveryTypes.contains(advertType)) return; + + setState(() { + _runningDiscoveryTypes.add(advertType); + }); + + try { + await context.read().discoverNodeType( + advertType: advertType, + ); + if (!mounted) return; + final label = switch (advertType) { + _repeaterAdvertType => 'Repeater discovery sent', + _sensorAdvertType => 'Sensor discovery sent', + _ => 'Discovery sent', + }; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(label))); + } finally { + if (mounted) { + setState(() { + _runningDiscoveryTypes.remove(advertType); + }); + } + } + } + Future _resolveAdvert(PendingAdvert advert) async { final keyHex = advert.publicKeyHex; if (_resolvingAdvertKeys.contains(keyHex)) return; @@ -36,7 +122,22 @@ class _DiscoveryScreenState extends State { }); try { - await context.read().getContact(advert.publicKey); + final connectionProvider = context.read(); + final contactsProvider = context.read(); + + await connectionProvider.getContact(advert.publicKey); + if (connectionProvider.error == 'Not found') { + connectionProvider.clearError(); + final fallbackContact = _contactFromPendingAdvert(advert); + await connectionProvider.addOrUpdateContact(fallbackContact); + contactsProvider.addOrUpdateContact( + fallbackContact, + devicePublicKey: connectionProvider.deviceInfo.publicKey, + ); + } + if ((advert.typeValue ?? 0) == _sensorAdvertType) { + await connectionProvider.requestTelemetry(advert.publicKey); + } } finally { if (mounted) { setState(() { @@ -76,11 +177,39 @@ class _DiscoveryScreenState extends State { return l10n.daysAgo(diff.inDays); } + Contact _contactFromPendingAdvert(PendingAdvert advert) { + final lastAdvert = + advert.lastAdvert ?? (advert.receivedAt.millisecondsSinceEpoch ~/ 1000); + final advName = advert.advName?.trim().isNotEmpty == true + ? advert.advName!.trim() + : advert.shortDisplayKey; + + return Contact( + publicKey: Uint8List.fromList(advert.publicKey), + type: ContactType.fromValue(advert.typeValue ?? 0), + flags: advert.flags ?? 0, + outPathLen: advert.signedEncodedPathLen ?? -1, + outPath: advert.paddedPathBytes == null + ? Uint8List(64) + : Uint8List.fromList(advert.paddedPathBytes!), + advName: advName, + lastAdvert: lastAdvert, + advLat: advert.advLat ?? 0, + advLon: advert.advLon ?? 0, + lastMod: lastAdvert, + ); + } + String _displayNameForAdvert( PendingAdvert advert, ContactsProvider contactsProvider, List cachedNodes, ) { + final advertisedName = advert.advName?.trim(); + if (advertisedName != null && advertisedName.isNotEmpty) { + return advertisedName; + } + Contact? existingMatch; for (final contact in contactsProvider.contacts) { if (contact.publicKeyHex == advert.publicKeyHex) { @@ -106,12 +235,144 @@ class _DiscoveryScreenState extends State { return advert.shortDisplayKey; } + IconData _iconForAdvert(PendingAdvert advert) { + return switch (advert.typeValue) { + _repeaterAdvertType => Icons.router_outlined, + _sensorAdvertType => Icons.sensors_outlined, + _ => Icons.campaign_outlined, + }; + } + + String? _typeLabelForAdvert(PendingAdvert advert) { + return switch (advert.typeValue) { + _repeaterAdvertType => 'Repeater', + _sensorAdvertType => 'Sensor', + 3 => 'Room', + 1 => 'Chat', + _ => null, + }; + } + + String? _resolvedTypeLabelForAdvert( + PendingAdvert advert, + List cachedNodes, + ) { + final directType = _typeLabelForAdvert(advert); + if (directType != null) { + return directType; + } + + for (final node in cachedNodes) { + if (node.publicKey == advert.publicKeyHex.toLowerCase()) { + return switch (node.type) { + 1 => 'Repeater', + 4 => 'Sensor', + 3 => 'Room', + 2 => 'Chat', + _ => null, + }; + } + } + + return null; + } + + Widget _buildAdvertTitle( + BuildContext context, { + required String displayName, + String? subtitle, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + if (subtitle != null && subtitle.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return Scaffold( - appBar: AppBar(title: const Text('Discovery')), + appBar: AppBar( + title: const Text('Discovery'), + actions: [ + Consumer( + builder: (context, connectionProvider, child) { + final isConnected = connectionProvider.deviceInfo.isConnected; + final repeatersBusy = _runningDiscoveryTypes.contains( + _repeaterAdvertType, + ); + final sensorsBusy = _runningDiscoveryTypes.contains( + _sensorAdvertType, + ); + + return PopupMenuButton<_DiscoveryMenuAction>( + tooltip: 'Discovery tools', + onSelected: _handleMenuAction, + itemBuilder: (context) => [ + PopupMenuItem<_DiscoveryMenuAction>( + value: _DiscoveryMenuAction.repeaters, + enabled: isConnected && !repeatersBusy, + child: Row( + children: [ + repeatersBusy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.router_outlined), + const SizedBox(width: 12), + const Text('Discover repeaters'), + ], + ), + ), + PopupMenuItem<_DiscoveryMenuAction>( + value: _DiscoveryMenuAction.sensors, + enabled: isConnected && !sensorsBusy, + child: Row( + children: [ + sensorsBusy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.sensors_outlined), + const SizedBox(width: 12), + const Text('Discover sensors'), + ], + ), + ), + ], + ); + }, + ), + ], + ), body: FutureBuilder>( future: _cachedNodesFuture, builder: (context, nodesSnapshot) => @@ -121,65 +382,98 @@ class _DiscoveryScreenState extends State { final isConnected = connectionProvider.deviceInfo.isConnected; final cachedNodes = nodesSnapshot.data ?? const []; - 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, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon(Icons.person_search), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Pending discoveries (${pendingAdverts.length})', + style: Theme.of(context).textTheme.titleMedium, ), - ) - : const Icon(Icons.download_for_offline_outlined), - label: const Text('Resolve all'), + ), + ], + ), + const SizedBox(height: 12), + Text( + 'Resolve entries manually so they do not auto-populate contacts.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isConnected && + pendingAdverts.isNotEmpty && + !_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(width: 10), + Expanded( + child: OutlinedButton.icon( + onPressed: pendingAdverts.isNotEmpty + ? _clearAllDiscoveries + : null, + icon: const Icon(Icons.clear_all_rounded), + label: const Text('Clear all'), + ), + ), + ], + ), + ], ), ), ), - const SizedBox(height: 12), + const SizedBox(height: 16), + if (pendingAdverts.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 48), + child: Column( + 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, + ), + ], + ), + ), ...pendingAdverts.map((advert) { final isResolving = _resolvingAdvertKeys.contains( advert.publicKeyHex, @@ -189,35 +483,92 @@ class _DiscoveryScreenState extends State { contactsProvider, cachedNodes, ); + final typeLabel = _resolvedTypeLabelForAdvert( + advert, + cachedNodes, + ); + final downMetric = SignalMetric.fromValues( + rssiDbm: advert.rxRssiDbm, + snrDb: advert.rxSnr, + ); + final upMetric = SignalMetric.fromValues( + rssiDbm: advert.repeaterLastRssi, + snrDb: advert.repeaterLastSnr, + ); + final detailLines = [ + '${l10n.publicKey}: ${advert.shortDisplayKey}', + ]; + final summaryParts = []; + final battery = advert.repeaterBatteryPercent; + if (battery != null) { + summaryParts.add('Battery ${battery.round()}%'); + } + if (advert.repeaterQueueLen != null) { + summaryParts.add('Queue ${advert.repeaterQueueLen}'); + } + if (summaryParts.isNotEmpty) { + detailLines.add(summaryParts.join(' • ')); + } + detailLines.add( + '${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}', + ); 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, + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + CircleAvatar( + child: Icon(_iconForAdvert(advert)), ), - ) - : IconButton( - icon: const Icon(Icons.person_add_alt_1), - tooltip: 'Resolve contact', - onPressed: isConnected - ? () => _resolveAdvert(advert) - : null, - ), + const SizedBox(width: 12), + Expanded( + child: _buildAdvertTitle( + context, + displayName: displayName, + subtitle: typeLabel, + ), + ), + if (downMetric != null || upMetric != null) ...[ + const SizedBox(width: 12), + _buildSignalSummary( + context, + downMetric: downMetric, + upMetric: upMetric, + ), + ], + const SizedBox(width: 8), + isResolving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : IconButton( + visualDensity: VisualDensity.compact, + icon: const Icon( + Icons.person_add_alt_1, + ), + tooltip: 'Resolve contact', + onPressed: isConnected + ? () => _resolveAdvert(advert) + : null, + ), + ], + ), + const SizedBox(height: 10), + Text( + detailLines.join('\n'), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), ), ); }), @@ -228,4 +579,82 @@ class _DiscoveryScreenState extends State { ), ); } + + Widget _buildSignalSummary( + BuildContext context, { + SignalMetric? downMetric, + SignalMetric? upMetric, + }) { + if (downMetric == null && upMetric == null) { + return const SizedBox.shrink(); + } + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (downMetric != null) + _buildDirectionalSignal( + context, + icon: Icons.south_west_rounded, + metric: downMetric, + ), + if (downMetric != null && upMetric != null) const SizedBox(height: 6), + if (upMetric != null) + _buildDirectionalSignal( + context, + icon: Icons.north_east_rounded, + metric: upMetric, + ), + ], + ); + } + + Widget _buildDirectionalSignal( + BuildContext context, { + required IconData icon, + required SignalMetric metric, + }) { + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + icon, + size: 11, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 3), + _buildMiniSignalBars(context, metric), + const SizedBox(width: 4), + Text( + metric.valueLabel, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ], + ); + } + + Widget _buildMiniSignalBars(BuildContext context, SignalMetric metric) { + final inactive = Theme.of(context).colorScheme.outlineVariant; + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + for (var index = 0; index < 3; index++) ...[ + if (index > 0) const SizedBox(width: 2), + Container( + width: 3, + height: 6.0 + (index * 4), + decoration: BoxDecoration( + color: index < metric.activeBars ? metric.color : inactive, + borderRadius: BorderRadius.circular(2), + ), + ), + ], + ], + ); + } } diff --git a/lib/screens/live_traffic_screen.dart b/lib/screens/live_traffic_screen.dart index ec1edf1..6120f70 100644 --- a/lib/screens/live_traffic_screen.dart +++ b/lib/screens/live_traffic_screen.dart @@ -11,6 +11,8 @@ import '../services/live_traffic_summary.dart'; import '../services/location_tracking_service.dart'; import '../services/route_hash_preferences.dart'; import '../utils/log_rx_route_decoder.dart'; +import '../widgets/compact_signal_indicator.dart'; +import '../widgets/messages/message_trace_sheet.dart'; import 'packet_log_screen.dart'; T? _maybeProvider(BuildContext context) { @@ -650,110 +652,101 @@ class _LiveTrafficCard extends StatelessWidget { final rxInfo = log.logRxDataInfo; final originDistance = _originDistanceLabel(context, entry); final packetDetails = _LiveTrafficPacketDetails.fromEntry(entry); - final signalMetric = _SignalMetric.fromRxInfo(rxInfo); + final signalMetric = SignalMetric.fromRxInfo(rxInfo); - return Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerLow, + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _showPacketBytesSheet(context, log.rawData), + onLongPress: () => _showTraceSheet(context, entry), borderRadius: BorderRadius.circular(18), - border: Border.all(color: accent.withValues(alpha: 0.25)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: accent.withValues(alpha: 0.25)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - decoration: BoxDecoration( - color: accent.withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(999), - ), - child: Text( - isRx ? 'RX' : 'TX', - style: TextStyle( - color: accent, - fontWeight: FontWeight.w800, - fontSize: 12, - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - packetDetails.title, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + isRx ? 'RX' : 'TX', + style: TextStyle( + color: accent, + fontWeight: FontWeight.w800, + fontSize: 12, ), ), - if (entry.payloadMeaning != null) - Text( - entry.payloadMeaning!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - if (signalMetric != null) ...[ - const SizedBox(width: 12), - _CompactSignalIndicator(metric: signalMetric), - ] else - Text( - _timeAgo(log.timestamp, now), - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurfaceVariant, ), - ), - ], - ), - const SizedBox(height: 10), - _PacketInfoLine( - text: - '${_formatClock(log.timestamp)} • Size: ${log.rawData.length} bytes', - ), - _PacketInfoLine(text: 'Hash: ${packetDetails.packetHashHex}'), - if (packetDetails.pathLine != null) - _PacketInfoLine(text: packetDetails.pathLine!), - if (packetDetails.pathHashLine != null) - _PacketInfoLine(text: packetDetails.pathHashLine!), - if (packetDetails.endpointLine != null) - _PacketInfoLine(text: packetDetails.endpointLine!), - const SizedBox(height: 10), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _PacketMetaChip( - label: '${log.rawData.length} bytes', - onTap: () => _showPacketBytesSheet(context, log.rawData), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + packetDetails.title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + if (entry.payloadMeaning != null) + Text( + entry.payloadMeaning!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + if (signalMetric != null) ...[ + const SizedBox(width: 12), + CompactSignalIndicator(metric: signalMetric), + ] else + Text( + _timeAgo(log.timestamp, now), + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], ), - if (entry.isMultiHop) - const _PacketMetaChip(label: 'MULTI-HOP', emphasized: true), + const SizedBox(height: 10), + _PacketInfoLine( + text: + '${_formatClock(log.timestamp)} • Size: ${log.rawData.length} bytes', + ), + _PacketInfoLine(text: 'Hash: ${packetDetails.packetHashHex}'), + if (packetDetails.pathLine != null) + _PacketInfoLine(text: packetDetails.pathLine!), + if (packetDetails.pathHashLine != null) + _PacketInfoLine(text: packetDetails.pathHashLine!), + if (packetDetails.endpointLine != null) + _PacketInfoLine(text: packetDetails.endpointLine!), if (originDistance != null) - _PacketMetaChip(label: 'Origin $originDistance'), - if (rxInfo?.rssiDbm != null) - _PacketMetaChip(label: 'RSSI ${rxInfo!.rssiDbm} dBm'), - if (rxInfo?.snrDb != null) - _PacketMetaChip( - label: 'SNR ${rxInfo!.snrDb!.toStringAsFixed(1)} dB', - ), + _PacketInfoLine(text: 'Origin: $originDistance'), ], ), - ], + ), ), ); } @@ -771,38 +764,6 @@ class _LiveTrafficCard extends StatelessWidget { return '$hour:$minute:$second'; } - static String _resolvedRoutePreview( - BuildContext context, - LiveTrafficEntry entry, - ) { - final route = entry.route; - if (route == null || route.hopHashes.isEmpty) { - return entry.routePreview; - } - - final contactsProvider = _maybeProvider(context); - final connectionProvider = _maybeProvider(context); - if (contactsProvider == null && connectionProvider == null) { - return entry.routePreview; - } - final ownLatLng = _ownLatLng(connectionProvider); - - final resolvedLabels = route.hopHashes.map((hashHex) { - final resolved = LogRxRouteDecoder.resolveHash( - hashHex, - contacts: contactsProvider?.contacts ?? const [], - ownPublicKey: connectionProvider?.deviceInfo.publicKey, - ownName: - connectionProvider?.deviceInfo.selfName ?? - connectionProvider?.deviceInfo.displayName, - ownLatitude: ownLatLng?.latitude, - ownLongitude: ownLatLng?.longitude, - ); - return _compactNodeLabel(resolved); - }).toList(); - return resolvedLabels.join(' -> '); - } - static String? _originDistanceLabel( BuildContext context, LiveTrafficEntry entry, @@ -962,15 +923,31 @@ class _LiveTrafficCard extends StatelessWidget { ); } - static String _compactNodeLabel(ResolvedNodeHash node) { - if (node.isOwnNode) { - return node.label; + static Future _showTraceSheet( + BuildContext context, + LiveTrafficEntry entry, + ) { + final route = entry.route; + if (route == null || route.pathBytes.isEmpty) { + return Future.value(); } - if (node.matchCount > 0) { - return node.label; - } - return node.hexLabel; + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => MessageTraceSheet.packetPath( + packetPath: route.pathBytes, + descriptionOverride: + 'Relay path from packet path bytes (${route.hopHashes.length} hop${route.hopHashes.length == 1 ? '' : 's'})', + noRelayMatchTextOverride: + 'No named nodes could be matched for this packet path.', + ), + ); } + } class _LiveTrafficPacketDetails { @@ -1129,96 +1106,6 @@ class _PacketInfoLine extends StatelessWidget { } } -class _SignalMetric { - final String valueLabel; - final Color color; - final int activeBars; - - const _SignalMetric({ - required this.valueLabel, - required this.color, - required this.activeBars, - }); - - static _SignalMetric? fromRxInfo(LogRxDataInfo? rxInfo) { - if (rxInfo == null) return null; - if (rxInfo?.snrDb != null) { - final snr = rxInfo.snrDb!; - return _SignalMetric( - valueLabel: '${snr.toStringAsFixed(1)}dB', - color: snr >= 10 - ? Colors.green - : snr >= 0 - ? Colors.amber - : Colors.redAccent, - activeBars: snr >= 10 - ? 3 - : snr >= 0 - ? 2 - : 1, - ); - } - if (rxInfo?.rssiDbm != null) { - final rssi = rxInfo.rssiDbm!; - return _SignalMetric( - valueLabel: '$rssi dBm', - color: rssi >= -80 - ? Colors.green - : rssi >= -95 - ? Colors.amber - : Colors.redAccent, - activeBars: rssi >= -80 - ? 3 - : rssi >= -95 - ? 2 - : 1, - ); - } - return null; - } -} - -class _CompactSignalIndicator extends StatelessWidget { - final _SignalMetric metric; - - const _CompactSignalIndicator({required this.metric}); - - @override - Widget build(BuildContext context) { - final inactive = Theme.of(context).colorScheme.outlineVariant; - return Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - for (var index = 0; index < 3; index++) ...[ - if (index > 0) const SizedBox(width: 3), - Container( - width: 5, - height: 10.0 + (index * 8), - decoration: BoxDecoration( - color: index < metric.activeBars ? metric.color : inactive, - borderRadius: BorderRadius.circular(2), - ), - ), - ], - ], - ), - const SizedBox(height: 6), - Text( - metric.valueLabel, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ); - } -} class _GeoPoint { final double latitude; @@ -1227,44 +1114,6 @@ class _GeoPoint { const _GeoPoint(this.latitude, this.longitude); } -class _PacketMetaChip extends StatelessWidget { - final String label; - final bool emphasized; - final VoidCallback? onTap; - - const _PacketMetaChip({ - required this.label, - this.emphasized = false, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final color = emphasized ? scheme.primary : scheme.outline; - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(999), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: color.withValues(alpha: emphasized ? 0.12 : 0.08), - borderRadius: BorderRadius.circular(999), - border: Border.all(color: color.withValues(alpha: 0.22)), - ), - child: Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: color, - ), - ), - ), - ); - } -} - void openLiveTrafficScreen(BuildContext context, ConnectionProvider provider) { Navigator.push( context, diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 8c9bb55..bd85437 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -519,6 +519,7 @@ class _MessagesTabState extends State { final l10n = AppLocalizations.of(context)!; final contactsProvider = context.read(); Contact? senderContact; + String? senderDisplayName; String destinationType; Contact? recipient; @@ -543,28 +544,48 @@ class _MessagesTabState extends State { if (senderPrefix != null && senderPrefix.length >= 6) { senderContact = contactsProvider.findContactByPrefix(senderPrefix); } + senderDisplayName = + senderContact?.displayName ?? message.senderName?.trim(); } else { - final senderPrefix = message.senderPublicKeyPrefix; - if (senderPrefix == null || senderPrefix.length < 6) { - ToastLogger.error(context, l10n.cannotReplySenderMissing); - return; - } + final roomRecipient = message.recipientPublicKey == null + ? null + : contactsProvider.findContactByKey(message.recipientPublicKey!); - recipient = contactsProvider.findContactByPrefix(senderPrefix); - if (recipient == null) { - ToastLogger.error(context, l10n.cannotReplyContactNotFound); - return; - } + if (roomRecipient?.isRoom == true) { + destinationType = MessageDestinationPreferences.destinationTypeRoom; + recipient = roomRecipient; - destinationType = recipient.isRoom - ? MessageDestinationPreferences.destinationTypeRoom - : MessageDestinationPreferences.destinationTypeContact; + final senderPrefix = message.senderPublicKeyPrefix; + if (senderPrefix != null && senderPrefix.length >= 6) { + senderContact = contactsProvider.findContactByPrefix(senderPrefix); + } + senderDisplayName = + senderContact?.displayName ?? message.senderName?.trim(); + } else { + final senderPrefix = message.senderPublicKeyPrefix; + if (senderPrefix == null || senderPrefix.length < 6) { + ToastLogger.error(context, l10n.cannotReplySenderMissing); + return; + } + + recipient = contactsProvider.findContactByPrefix(senderPrefix); + if (recipient == null) { + ToastLogger.error(context, l10n.cannotReplyContactNotFound); + return; + } + + destinationType = recipient.isRoom + ? MessageDestinationPreferences.destinationTypeRoom + : MessageDestinationPreferences.destinationTypeContact; + } } await _onRecipientSelected(destinationType, recipient); if (!mounted) return; - if (message.isChannelMessage && senderContact != null) { - _insertReplyMention(senderContact.displayName); + if ((message.isChannelMessage || recipient?.isRoom == true) && + senderDisplayName != null && + senderDisplayName.isNotEmpty) { + _insertReplyMention(senderDisplayName); } _focusNode.requestFocus(); } diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 310b247..495642b 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -26,6 +26,7 @@ class NotificationService { bool _updateNotificationsEnabled = true; bool _muteForegroundNotifications = true; AppLifecycleState _lifecycleState = AppLifecycleState.resumed; + String? _launchPayload; // Notification IDs static const int _sarNotificationId = 1000; @@ -103,6 +104,12 @@ class NotificationService { onDidReceiveNotificationResponse: _onNotificationResponse, ); + final launchDetails = await _notificationsPlugin + .getNotificationAppLaunchDetails(); + if (launchDetails?.didNotificationLaunchApp ?? false) { + _launchPayload = launchDetails?.notificationResponse?.payload; + } + // Request permissions await _requestPermissions(); await _loadPreferences(); @@ -295,6 +302,12 @@ class NotificationService { } } + String? consumeLaunchPayload() { + final payload = _launchPayload; + _launchPayload = null; + return payload; + } + /// Show urgent notification for SAR marker Future showSarNotification({ required SarMarkerType type, @@ -837,6 +850,7 @@ class NotificationService { Future showContactDiscoveredNotification({ required String contactKey, + String? contactName, }) async { if (!_isInitialized) return false; if (!_permissionGranted) return false; @@ -847,7 +861,10 @@ class NotificationService { ? contactKey.substring(0, 12).toUpperCase() : contactKey.toUpperCase(); final title = 'New contact discovered'; - final body = 'New contact $shortKey is available in Discovery.'; + final resolvedName = contactName?.trim(); + final body = resolvedName != null && resolvedName.isNotEmpty + ? '$resolvedName is available in Discovery.' + : 'New contact $shortKey is available in Discovery.'; final notificationId = _discoveryNotificationId + ((contactKey.hashCode & 0x7fffffff) % 1000); diff --git a/lib/widgets/compact_signal_indicator.dart b/lib/widgets/compact_signal_indicator.dart new file mode 100644 index 0000000..48cd5b7 --- /dev/null +++ b/lib/widgets/compact_signal_indicator.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:meshcore_client/meshcore_client.dart' show LogRxDataInfo; + +class SignalMetric { + final String valueLabel; + final Color color; + final int activeBars; + + const SignalMetric({ + required this.valueLabel, + required this.color, + required this.activeBars, + }); + + static SignalMetric? fromRxInfo(LogRxDataInfo? rxInfo) { + if (rxInfo == null) return null; + return fromValues(rssiDbm: rxInfo.rssiDbm, snrDb: rxInfo.snrDb); + } + + static SignalMetric? fromValues({int? rssiDbm, double? snrDb}) { + if (snrDb != null) { + return SignalMetric( + valueLabel: '${snrDb.toStringAsFixed(1)}dB', + color: snrDb >= 10 + ? Colors.green + : snrDb >= 0 + ? Colors.amber + : Colors.redAccent, + activeBars: snrDb >= 10 + ? 3 + : snrDb >= 0 + ? 2 + : 1, + ); + } + if (rssiDbm != null) { + return SignalMetric( + valueLabel: '$rssiDbm dBm', + color: rssiDbm >= -80 + ? Colors.green + : rssiDbm >= -95 + ? Colors.amber + : Colors.redAccent, + activeBars: rssiDbm >= -80 + ? 3 + : rssiDbm >= -95 + ? 2 + : 1, + ); + } + return null; + } +} + +class CompactSignalIndicator extends StatelessWidget { + final SignalMetric metric; + final bool dense; + + const CompactSignalIndicator({ + super.key, + required this.metric, + this.dense = false, + }); + + @override + Widget build(BuildContext context) { + final inactive = Theme.of(context).colorScheme.outlineVariant; + final barWidth = dense ? 4.0 : 5.0; + final baseHeight = dense ? 8.0 : 10.0; + final barStep = dense ? 6.0 : 8.0; + final barSpacing = dense ? 2.0 : 3.0; + final labelGap = dense ? 4.0 : 6.0; + final labelFontSize = dense ? 10.0 : 12.0; + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + for (var index = 0; index < 3; index++) ...[ + if (index > 0) SizedBox(width: barSpacing), + Container( + width: barWidth, + height: baseHeight + (index * barStep), + decoration: BoxDecoration( + color: index < metric.activeBars ? metric.color : inactive, + borderRadius: BorderRadius.circular(2), + ), + ), + ], + ], + ), + SizedBox(height: labelGap), + Text( + metric.valueLabel, + style: TextStyle( + fontSize: labelFontSize, + fontWeight: FontWeight.w700, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/messages/message_trace_sheet.dart b/lib/widgets/messages/message_trace_sheet.dart index 449161b..5638353 100644 --- a/lib/widgets/messages/message_trace_sheet.dart +++ b/lib/widgets/messages/message_trace_sheet.dart @@ -17,9 +17,24 @@ import '../../utils/log_rx_route_decoder.dart'; import '../../utils/trace_node_resolver.dart'; class MessageTraceSheet extends StatefulWidget { - final Message message; + final Message? message; + final List? packetPathOverride; + final String? descriptionOverride; + final String? noRelayMatchTextOverride; - const MessageTraceSheet({super.key, required this.message}); + const MessageTraceSheet({super.key, required this.message}) + : assert(message != null), + packetPathOverride = null, + descriptionOverride = null, + noRelayMatchTextOverride = null; + + const MessageTraceSheet.packetPath({ + super.key, + required List packetPath, + this.descriptionOverride, + this.noRelayMatchTextOverride, + }) : message = null, + packetPathOverride = packetPath; @override State createState() => _MessageTraceSheetState(); @@ -40,20 +55,27 @@ class _MessageTraceSheetState extends State { final contactsProvider = context.read(); final messagesProvider = context.read(); final preferredHashSize = await RouteHashPreferences.getHashSize(); - final storedPath = messagesProvider - .getMessageReceptionDetails(widget.message.id) - ?.pathBytes; - final packetPath = (storedPath != null && storedPath.isNotEmpty) - ? storedPath - : _extractPathFromPacketLogs( - logs: connectionProvider.bleService.packetLogs, - message: widget.message, - ); + List? packetPath = widget.packetPathOverride; + String? senderPrefix; + String? recipientPrefix; + + if (widget.message case final message?) { + final storedPath = messagesProvider + .getMessageReceptionDetails(message.id) + ?.pathBytes; + packetPath = (storedPath != null && storedPath.isNotEmpty) + ? storedPath + : _extractPathFromPacketLogs( + logs: connectionProvider.bleService.packetLogs, + message: message, + ); + + senderPrefix = _toPrefixHex(message.senderPublicKeyPrefix); + recipientPrefix = message.recipientPublicKey != null + ? _toPrefixHex(message.recipientPublicKey) + : _toPrefixHex(connectionProvider.deviceInfo.publicKey); + } - final senderPrefix = _toPrefixHex(widget.message.senderPublicKeyPrefix); - final recipientPrefix = widget.message.recipientPublicKey != null - ? _toPrefixHex(widget.message.recipientPublicKey) - : _toPrefixHex(connectionProvider.deviceInfo.publicKey); final localNodes = _localNodesFromContacts(contactsProvider); final localPublicKeys = localNodes.map((node) => node.publicKey).toSet(); var trace = _buildTraceResult( @@ -66,7 +88,9 @@ class _MessageTraceSheetState extends State { ); if (_isCompleteTrace( trace, - expectedRelayCount: math.max(0, widget.message.pathLen), + expectedRelayCount: widget.message == null + ? 0 + : math.max(0, widget.message!.pathLen), )) { return trace; } @@ -153,9 +177,10 @@ class _MessageTraceSheetState extends State { Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Text( - trace.mode == TraceMode.packetPath - ? 'Relay path from packet path bytes' - : 'Relay path inferred from hop count (${widget.message.pathLen})', + widget.descriptionOverride ?? + (trace.mode == TraceMode.packetPath + ? 'Relay path from packet path bytes' + : 'Relay path inferred from hop count (${widget.message!.pathLen})'), style: Theme.of(context).textTheme.bodySmall, ), ), @@ -257,13 +282,14 @@ class _MessageTraceSheetState extends State { ), ), if (routeEntries.isEmpty) - const Padding( - padding: EdgeInsets.symmetric( + Padding( + padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 8, ), child: Text( - 'No named nodes could be matched for this trace.', + widget.noRelayMatchTextOverride ?? + 'No named nodes could be matched for this trace.', ), ), ...routeEntries.asMap().entries.map( @@ -345,6 +371,9 @@ class _MessageTraceSheetState extends State { if (concrete.isEmpty) return const []; if (trace.mode == TraceMode.packetPath) { + if (widget.message == null) { + return concrete; + } if (concrete.length <= 1) return const []; return concrete.sublist(1); } @@ -488,7 +517,7 @@ class _MessageTraceSheetState extends State { nodes: nodes, sender: senderNode.node, recipient: recipientNode.node, - relayCount: math.max(0, widget.message.pathLen), + relayCount: math.max(0, widget.message!.pathLen), ); final matchedPathNodes = [ if (senderNode.node != null) senderNode, diff --git a/pubspec.lock b/pubspec.lock index c192f51..af69e4f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -795,8 +795,8 @@ packages: dependency: "direct main" description: path: "." - ref: "55e303a" - resolved-ref: "55e303aa71d9fd847cedd11bc1243504fbe363e0" + ref: "813f5b3" + resolved-ref: "813f5b3e0b9d2ea6b85a428be443bf5e6a38c6c5" url: "https://github.com/dz0ny/meshcore_client.git" source: git version: "0.1.0" diff --git a/pubspec.yaml b/pubspec.yaml index 612b781..0e24559 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,7 +44,7 @@ dependencies: meshcore_client: git: url: https://github.com/dz0ny/meshcore_client.git - ref: "55e303a" + ref: "813f5b3" # Codec2 ultra-low-bitrate speech codec (FFI plugin) codec2_flutter: