feat: Finish discovery flow

ref:
This commit is contained in:
Janez T
2026-03-14 21:04:23 +01:00
parent 6e9e9e397d
commit 6de12225fc
14 changed files with 2170 additions and 404 deletions

View File

@@ -25,6 +25,7 @@ import 'services/locale_preferences.dart';
import 'services/mesh_map_nodes_service.dart'; import 'services/mesh_map_nodes_service.dart';
import 'services/update_checker_service.dart'; import 'services/update_checker_service.dart';
import 'services/wizard_preferences.dart'; import 'services/wizard_preferences.dart';
import 'screens/discovery_screen.dart';
import 'screens/home_screen.dart'; import 'screens/home_screen.dart';
import 'screens/welcome_wizard_screen.dart'; import 'screens/welcome_wizard_screen.dart';
import 'theme/app_theme.dart'; import 'theme/app_theme.dart';
@@ -42,11 +43,13 @@ class MeshCoreSarApp extends StatefulWidget {
} }
class _MeshCoreSarAppState extends State<MeshCoreSarApp> { class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
AppThemeMode _themeMode = AppThemeMode.system; AppThemeMode _themeMode = AppThemeMode.system;
Locale? _locale; Locale? _locale;
bool _isInitialized = false; bool _isInitialized = false;
bool _shouldShowPermissionDialog = false; bool _shouldShowPermissionDialog = false;
bool _wizardCompleted = true; // Will be updated in _initializeApp() bool _wizardCompleted = true; // Will be updated in _initializeApp()
String? _pendingNotificationPayload;
@override @override
void initState() { void initState() {
@@ -66,6 +69,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
// Set up notification tap handler for update notifications // Set up notification tap handler for update notifications
NotificationService().onNotificationTapped = _handleNotificationTap; NotificationService().onNotificationTapped = _handleNotificationTap;
_pendingNotificationPayload = NotificationService().consumeLaunchPayload();
// Check if we need to request location permissions // Check if we need to request location permissions
await _checkLocationPermissions(); await _checkLocationPermissions();
@@ -81,6 +85,14 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
_wizardCompleted = wizardCompleted; _wizardCompleted = wizardCompleted;
_isInitialized = true; _isInitialized = true;
}); });
if (_pendingNotificationPayload != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final payload = _pendingNotificationPayload;
_pendingNotificationPayload = null;
_handleNotificationTap(payload);
});
}
} }
/// Handle notification tap /// Handle notification tap
@@ -89,10 +101,27 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
debugPrint('[Main] Notification tapped: $payload'); debugPrint('[Main] Notification tapped: $payload');
if (!_wizardCompleted) {
_pendingNotificationPayload = payload;
return;
}
// Handle update notification tap // Handle update notification tap
if (payload.startsWith('update:')) { if (payload.startsWith('update:')) {
final downloadUrl = payload.substring(7); // Remove 'update:' prefix final downloadUrl = payload.substring(7); // Remove 'update:' prefix
_launchUpdateDownload(downloadUrl); _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 // SAR and message notifications handled by their respective providers
} }
@@ -304,6 +333,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
builder: (context) { builder: (context) {
final systemBrightness = MediaQuery.platformBrightnessOf(context); final systemBrightness = MediaQuery.platformBrightnessOf(context);
final materialApp = MaterialApp( final materialApp = MaterialApp(
navigatorKey: _navigatorKey,
key: ValueKey<String?>( key: ValueKey<String?>(
'${_locale?.languageCode ?? 'system'}_${_themeMode.name}', '${_locale?.languageCode ?? 'system'}_${_themeMode.name}',
), ),

View File

@@ -66,6 +66,11 @@ class DeviceInfo {
final int? advLat; final int? advLat;
final int? advLon; final int? advLon;
final bool? manualAddContacts; final bool? manualAddContacts;
final bool? autoAddUsers;
final bool? autoAddRepeaters;
final bool? autoAddRoomServers;
final bool? autoAddSensors;
final bool? autoAddOverwriteOldest;
final int? radioFreq; final int? radioFreq;
final int? radioBw; final int? radioBw;
final int? radioSf; final int? radioSf;
@@ -113,6 +118,11 @@ class DeviceInfo {
this.advLat, this.advLat,
this.advLon, this.advLon,
this.manualAddContacts, this.manualAddContacts,
this.autoAddUsers,
this.autoAddRepeaters,
this.autoAddRoomServers,
this.autoAddSensors,
this.autoAddOverwriteOldest,
this.radioFreq, this.radioFreq,
this.radioBw, this.radioBw,
this.radioSf, this.radioSf,
@@ -239,6 +249,11 @@ class DeviceInfo {
int? advLat, int? advLat,
int? advLon, int? advLon,
bool? manualAddContacts, bool? manualAddContacts,
bool? autoAddUsers,
bool? autoAddRepeaters,
bool? autoAddRoomServers,
bool? autoAddSensors,
bool? autoAddOverwriteOldest,
int? radioFreq, int? radioFreq,
int? radioBw, int? radioBw,
int? radioSf, int? radioSf,
@@ -278,6 +293,12 @@ class DeviceInfo {
advLat: advLat ?? this.advLat, advLat: advLat ?? this.advLat,
advLon: advLon ?? this.advLon, advLon: advLon ?? this.advLon,
manualAddContacts: manualAddContacts ?? this.manualAddContacts, 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, radioFreq: radioFreq ?? this.radioFreq,
radioBw: radioBw ?? this.radioBw, radioBw: radioBw ?? this.radioBw,
radioSf: radioSf ?? this.radioSf, radioSf: radioSf ?? this.radioSf,

View File

@@ -1,6 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:meshcore_client/meshcore_client.dart' show BufferReader;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'connection_provider.dart'; import 'connection_provider.dart';
import 'contacts_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 /// Main App Provider - coordinates all other providers
class AppProvider with ChangeNotifier { class AppProvider with ChangeNotifier {
static const int _maxDirectPayloadHops = 3; static const int _maxDirectPayloadHops = 3;
static const 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 _lowBatteryThresholdPercent = 30.0;
static const double _lowBatteryResetThresholdPercent = 35.0; static const double _lowBatteryResetThresholdPercent = 35.0;
static const Duration _lowBatteryCheckInterval = Duration(minutes: 5); static const Duration _lowBatteryCheckInterval = Duration(minutes: 5);
static const Duration _repeaterOwnerInfoRequestCooldown = Duration(
minutes: 10,
);
@visibleForTesting @visibleForTesting
static bool isDeletedChannelInfo( static bool isDeletedChannelInfo(
int channelIdx, int channelIdx,
@@ -151,6 +206,10 @@ class AppProvider with ChangeNotifier {
final Map<String, Future<bool>> _pendingMediaSwarmFetches = {}; final Map<String, Future<bool>> _pendingMediaSwarmFetches = {};
final Map<String, Map<String, MediaSwarmAvailability>> final Map<String, Map<String, MediaSwarmAvailability>>
_pendingMediaSwarmResponses = {}; _pendingMediaSwarmResponses = {};
final Map<String, DateTime> _recentRepeaterStatusRequests = {};
final Map<String, DateTime> _recentRepeaterOwnerInfoRequests = {};
final Map<int, _PendingRepeaterOwnerRequest> _pendingRepeaterOwnerRequests =
{};
bool _fastLocationScreenActive = false; bool _fastLocationScreenActive = false;
Timer? _packetCaptureFlushTimer; Timer? _packetCaptureFlushTimer;
Timer? _lowBatteryCheckTimer; Timer? _lowBatteryCheckTimer;
@@ -864,11 +923,47 @@ class AppProvider with ChangeNotifier {
); );
}; };
// When a contact is received from BLE // 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 // Pass device public key to filter out our own contact
contactsProvider.addOrUpdateContact( contactsProvider.addOrUpdateContact(
contact, contact,
devicePublicKey: connectionProvider.deviceInfo.publicKey, devicePublicKey: devicePublicKey,
); );
unawaited(_pathHistoryService.recordLearnedPath(contact)); unawaited(_pathHistoryService.recordLearnedPath(contact));
@@ -1240,6 +1335,9 @@ class AppProvider with ChangeNotifier {
// Used by newer firmware versions for telemetry and other binary data // Used by newer firmware versions for telemetry and other binary data
// BOTH callbacks (0x8B and 0x8C) must be handled for device compatibility // BOTH callbacks (0x8B and 0x8C) must be handled for device compatibility
connectionProvider.onBinaryResponse = (publicKeyPrefix, tag, responseData) { connectionProvider.onBinaryResponse = (publicKeyPrefix, tag, responseData) {
if (_handlePendingRepeaterOwnerResponse(tag, responseData)) {
return;
}
debugPrint( debugPrint(
'📊 [AppProvider] Binary response (0x8C) received - updating contact telemetry', '📊 [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 0x69 'i' = image fetch request; 0x56 'V' = voice packet.
// Magic 0x49 'I' = image packet. // Magic 0x49 'I' = image packet.
connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) { 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); final fastGpsPacket = FastGpsPacket.tryParseBinary(payload);
if (fastGpsPacket != null) { if (fastGpsPacket != null) {
final sender = _resolveContactByPrefixHex(fastGpsPacket.senderKey6); final sender = _resolveContactByPrefixHex(fastGpsPacket.senderKey6);
@@ -1458,6 +1593,31 @@ class AppProvider with ChangeNotifier {
_handleIncomingVoicePacket(pkt, justComplete: justComplete); _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 // When a contact's routing path is updated in the mesh network
connectionProvider.onPathUpdated = (publicKey) { connectionProvider.onPathUpdated = (publicKey) {
debugPrint( debugPrint(
@@ -1509,9 +1669,13 @@ class AppProvider with ChangeNotifier {
unawaited( unawaited(
_notificationService.showContactDiscoveredNotification( _notificationService.showContactDiscoveredNotification(
contactKey: keyHex, contactKey: keyHex,
contactName: contactsProvider.pendingAdvertByKey(publicKey)?.advName,
), ),
); );
} }
if (contactsProvider.shouldEnrichPendingAdvert(publicKey)) {
unawaited(connectionProvider.previewContact(publicKey));
}
} }
}; };
@@ -1923,6 +2087,314 @@ class AppProvider with ChangeNotifier {
); );
} }
Future<void> _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<void> _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( int _inferReceivedPathHashSize(
List<int> pathBytes, { List<int> pathBytes, {
required int preferredHashSize, required int preferredHashSize,
@@ -3142,6 +3614,68 @@ class AppProvider with ChangeNotifier {
notifyListeners(); 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 /// Get app statistics
Map<String, dynamic> get statistics { Map<String, dynamic> get statistics {
return { return {

View File

@@ -1,5 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart';
@@ -55,8 +57,21 @@ class ScannedDevice {
ScannedDevice({required this.device, required this.rssi}); 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) /// Connection Provider - manages MeshCore device connection (BLE or TCP/WiFi)
class ConnectionProvider with ChangeNotifier { class ConnectionProvider with ChangeNotifier {
static const int _controlTypeNodeDiscoverReq = 0x80;
final MeshCoreBleService _bleService = MeshCoreBleService(); final MeshCoreBleService _bleService = MeshCoreBleService();
final SseServerService _sseServer = SseServerService(); final SseServerService _sseServer = SseServerService();
MeshCoreTcpService? _tcpService; MeshCoreTcpService? _tcpService;
@@ -145,6 +160,13 @@ class ConnectionProvider with ChangeNotifier {
bool _isAdvertInProgress = false; bool _isAdvertInProgress = false;
DateTime? _lastAdvertRequestedAt; DateTime? _lastAdvertRequestedAt;
static const Duration _minAdvertInterval = Duration(milliseconds: 500); static const Duration _minAdvertInterval = Duration(milliseconds: 500);
bool _isContactsSyncInProgress = false;
final Map<String, _PendingContactRequest> _pendingSingleContactRequests = {};
final Map<String, DateTime> _previewContactMisses = {};
static const Duration _singleContactRequestWindow = Duration(seconds: 10);
static const Duration _previewContactMissTtl = Duration(minutes: 10);
bool _suppressNextPreviewNotFoundError = false;
bool? _supportsAutoaddConfig;
// Helper instances // Helper instances
final RoomLoginManager _roomLoginManager = RoomLoginManager(); final RoomLoginManager _roomLoginManager = RoomLoginManager();
@@ -162,6 +184,7 @@ class ConnectionProvider with ChangeNotifier {
// Callbacks for other providers // Callbacks for other providers
Function(Contact)? onContactReceived; Function(Contact)? onContactReceived;
Function(Contact, ContactReceiveSource)? onContactReceivedDetailed;
Function(List<Contact>)? onContactsComplete; Function(List<Contact>)? onContactsComplete;
Function(Message)? onMessageReceived; Function(Message)? onMessageReceived;
Function(Uint8List publicKey, Uint8List lppData)? onTelemetryReceived; Function(Uint8List publicKey, Uint8List lppData)? onTelemetryReceived;
@@ -189,6 +212,8 @@ class ConnectionProvider with ChangeNotifier {
onMessageEchoDetected; onMessageEchoDetected;
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse; Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived; Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived;
Function(Uint8List payload, int snrRaw, int rssiDbm, int pathLen)?
onControlDataReceived;
Contact? Function(Uint8List contactPublicKey)? resolveContactForDmCallback; Contact? Function(Uint8List contactPublicKey)? resolveContactForDmCallback;
bool Function()? canStartAutomaticMessageSyncCallback; bool Function()? canStartAutomaticMessageSyncCallback;
@@ -241,6 +266,10 @@ class ConnectionProvider with ChangeNotifier {
}; };
service.onError = (error, {int? errorCode}) { service.onError = (error, {int? errorCode}) {
if (errorCode == 2 && _suppressNextPreviewNotFoundError) {
_suppressNextPreviewNotFoundError = false;
return;
}
debugPrint('⚠️ [Provider] Error received: $error'); debugPrint('⚠️ [Provider] Error received: $error');
_error = error; _error = error;
if (_deviceInfo.connectionState != ConnectionState.connected) { if (_deviceInfo.connectionState != ConnectionState.connected) {
@@ -252,9 +281,21 @@ class ConnectionProvider with ChangeNotifier {
}; };
service.onContactNotFound = (contactPublicKey) async { service.onContactNotFound = (contactPublicKey) async {
debugPrint('🔧 [Provider] Contact not found - initiating auto-recovery');
if (contactPublicKey == null) return; 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 final operationId = contactPublicKey
.sublist(0, 6) .sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0')) .map((b) => b.toRadixString(16).padLeft(2, '0'))
@@ -288,11 +329,14 @@ class ConnectionProvider with ChangeNotifier {
service.onContactReceived = (contact) { service.onContactReceived = (contact) {
debugPrint('📥 [Provider] Contact received: "${contact.advName}"'); debugPrint('📥 [Provider] Contact received: "${contact.advName}"');
final source = _classifyContactReceiveSource(contact.publicKey);
onContactReceivedDetailed?.call(contact, source);
onContactReceived?.call(contact); onContactReceived?.call(contact);
}; };
service.onContactsComplete = (contacts) { service.onContactsComplete = (contacts) {
debugPrint('📥 [Provider] Contacts sync complete: ${contacts.length}'); debugPrint('📥 [Provider] Contacts sync complete: ${contacts.length}');
_isContactsSyncInProgress = false;
if (_contactsSyncCompleter != null && if (_contactsSyncCompleter != null &&
!_contactsSyncCompleter!.isCompleted) { !_contactsSyncCompleter!.isCompleted) {
_contactsSyncCompleter!.complete(); _contactsSyncCompleter!.complete();
@@ -408,6 +452,8 @@ class ConnectionProvider with ChangeNotifier {
service.onRawDataReceived = (payload, snrRaw, rssiDbm) => service.onRawDataReceived = (payload, snrRaw, rssiDbm) =>
onRawDataReceived?.call(payload, snrRaw, rssiDbm); onRawDataReceived?.call(payload, snrRaw, rssiDbm);
service.onControlDataReceived = (payload, snrRaw, rssiDbm, pathLen) =>
onControlDataReceived?.call(payload, snrRaw, rssiDbm, pathLen);
service.onDeviceInfoReceived = (deviceInfo) { service.onDeviceInfoReceived = (deviceInfo) {
debugPrint('📥 [Provider] DeviceInfo received'); debugPrint('📥 [Provider] DeviceInfo received');
@@ -481,6 +527,17 @@ class ConnectionProvider with ChangeNotifier {
notifyListeners(); 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 = () { service.onTxActivity = () {
_txActivity = true; _txActivity = true;
notifyListeners(); notifyListeners();
@@ -600,6 +657,7 @@ class ConnectionProvider with ChangeNotifier {
connectionState: ConnectionState.connecting, connectionState: ConnectionState.connecting,
); );
_error = null; _error = null;
_supportsAutoaddConfig = null;
_resetSyncState(); _resetSyncState();
debugPrint('✅ [Provider] Device info updated to connecting state'); debugPrint('✅ [Provider] Device info updated to connecting state');
notifyListeners(); notifyListeners();
@@ -630,6 +688,7 @@ class ConnectionProvider with ChangeNotifier {
connectionState: ConnectionState.connecting, connectionState: ConnectionState.connecting,
); );
_error = null; _error = null;
_supportsAutoaddConfig = null;
notifyListeners(); notifyListeners();
// Create fresh TCP service and wire its callbacks // Create fresh TCP service and wire its callbacks
@@ -658,6 +717,7 @@ class ConnectionProvider with ChangeNotifier {
} }
_tcpHost = null; _tcpHost = null;
_connectionMode = ConnectionMode.ble; _connectionMode = ConnectionMode.ble;
_supportsAutoaddConfig = null;
_resetSyncState(); _resetSyncState();
_deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected); _deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected);
_roomLoginManager.clearRoomLoginStates(); _roomLoginManager.clearRoomLoginStates();
@@ -681,6 +741,7 @@ class ConnectionProvider with ChangeNotifier {
await _bleService.disconnect(); await _bleService.disconnect();
_supportsAutoaddConfig = null;
_resetSyncState(); _resetSyncState();
_deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected); _deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected);
_roomLoginManager.clearRoomLoginStates(); _roomLoginManager.clearRoomLoginStates();
@@ -761,6 +822,7 @@ class ConnectionProvider with ChangeNotifier {
} }
try { try {
_isContactsSyncInProgress = true;
_contactsSyncCompleter = Completer<void>(); _contactsSyncCompleter = Completer<void>();
await _activeService.getContacts(); await _activeService.getContacts();
await _contactsSyncCompleter!.future.timeout( await _contactsSyncCompleter!.future.timeout(
@@ -772,9 +834,11 @@ class ConnectionProvider with ChangeNotifier {
}, },
); );
} catch (e) { } catch (e) {
_isContactsSyncInProgress = false;
_error = 'Failed to get contacts: $e'; _error = 'Failed to get contacts: $e';
notifyListeners(); notifyListeners();
} finally { } finally {
_isContactsSyncInProgress = false;
_contactsSyncCompleter = null; _contactsSyncCompleter = null;
} }
} }
@@ -793,6 +857,10 @@ class ConnectionProvider with ChangeNotifier {
} }
try { try {
_markSingleContactRequested(
publicKey,
source: ContactReceiveSource.requestedSingle,
);
await _activeService.getContactByKey(publicKey); await _activeService.getContactByKey(publicKey);
} catch (e) { } catch (e) {
_error = 'Failed to get contact: $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', '⚠️ [Provider] Failed to get contact by key, falling back to full contact sync',
); );
// Fallback to full contact sync if command not supported // Fallback to full contact sync if command not supported
_isContactsSyncInProgress = true;
await _activeService.getContacts(); await _activeService.getContacts();
notifyListeners(); notifyListeners();
} }
} }
Future<void> 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 /// Sync all channels from device
Future<void> syncChannels({int? maxChannels}) async { Future<void> syncChannels({int? maxChannels}) async {
if (!_activeService.isConnected) { if (!_activeService.isConnected) {
@@ -1656,6 +1794,40 @@ class ConnectionProvider with ChangeNotifier {
} }
} }
Future<void> 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<int>.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 /// Get device time from companion radio to detect clock drift
Future<void> getDeviceTime() async { Future<void> getDeviceTime() async {
if (!_activeService.isConnected) { if (!_activeService.isConnected) {
@@ -1892,6 +2064,80 @@ class ConnectionProvider with ChangeNotifier {
} }
} }
Future<void> 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<void> 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) /// Request fresh device info (triggers SelfInfo response)
Future<void> refreshDeviceInfo() async { Future<void> refreshDeviceInfo() async {
if (_isSpectrumScanActive) return; if (_isSpectrumScanActive) return;
@@ -1904,14 +2150,35 @@ class ConnectionProvider with ChangeNotifier {
try { try {
// The device query command triggers a SelfInfo response // The device query command triggers a SelfInfo response
await _activeService.refreshDeviceInfo(); await _activeService.refreshDeviceInfo();
// Also request allowed repeat frequencies (firmware v9+, no-op on older firmware) if (_supportsAutoaddConfig != false) {
await _activeService.getAllowedRepeatFreq(); 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) { } catch (e) {
_error = 'Failed to refresh device info: $e'; _error = 'Failed to refresh device info: $e';
notifyListeners(); notifyListeners();
} }
} }
bool _isUnsupportedAutoaddConfigError(Object error) {
final message = error.toString().toLowerCase();
return message.contains('illegal argument') ||
message.contains('unsupported');
}
/// Request battery and storage information /// Request battery and storage information
/// ///
/// Queries the companion radio for: /// 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 /// Reset routing path for a contact
/// ///
/// Clears the learned path to a contact, forcing the next message to use /// Clears the learned path to a contact, forcing the next message to use

View File

@@ -13,14 +13,40 @@ import '../utils/key_comparison.dart';
class PendingAdvert { class PendingAdvert {
final Uint8List publicKey; final Uint8List publicKey;
final DateTime receivedAt; 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 int? signedEncodedPathLen;
final Uint8List? paddedPathBytes; 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({ const PendingAdvert({
required this.publicKey, required this.publicKey,
required this.receivedAt, required this.receivedAt,
this.advName,
this.typeValue,
this.flags,
this.lastAdvert,
this.advLat,
this.advLon,
this.signedEncodedPathLen, this.signedEncodedPathLen,
this.paddedPathBytes, this.paddedPathBytes,
this.rxRssiDbm,
this.rxSnrRaw,
this.repeaterBatteryMv,
this.repeaterQueueLen,
this.repeaterLastRssi,
this.repeaterLastSnrRaw,
this.repeaterUptimeSecs,
}); });
String get publicKeyHex => String get publicKeyHex =>
@@ -34,16 +60,55 @@ class PendingAdvert {
PendingAdvert copyWith({ PendingAdvert copyWith({
Uint8List? publicKey, Uint8List? publicKey,
DateTime? receivedAt, DateTime? receivedAt,
String? advName,
int? typeValue,
int? flags,
int? lastAdvert,
int? advLat,
int? advLon,
int? signedEncodedPathLen, int? signedEncodedPathLen,
Uint8List? paddedPathBytes, Uint8List? paddedPathBytes,
int? rxRssiDbm,
int? rxSnrRaw,
int? repeaterBatteryMv,
int? repeaterQueueLen,
int? repeaterLastRssi,
int? repeaterLastSnrRaw,
int? repeaterUptimeSecs,
}) { }) {
return PendingAdvert( return PendingAdvert(
publicKey: publicKey ?? this.publicKey, publicKey: publicKey ?? this.publicKey,
receivedAt: receivedAt ?? this.receivedAt, 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, signedEncodedPathLen: signedEncodedPathLen ?? this.signedEncodedPathLen,
paddedPathBytes: paddedPathBytes ?? this.paddedPathBytes, 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 { class _RetainedRoute {
@@ -257,6 +322,28 @@ class ContactsProvider with ChangeNotifier {
_pendingAdverts.values.toList() _pendingAdverts.values.toList()
..sort((a, b) => b.receivedAt.compareTo(a.receivedAt)); ..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<SavedContactGroup> savedGroupsForSection(String sectionKey) { List<SavedContactGroup> savedGroupsForSection(String sectionKey) {
return savedContactGroups return savedContactGroups
.where((group) => group.sectionKey == sectionKey) .where((group) => group.sectionKey == sectionKey)
@@ -466,7 +553,6 @@ class ContactsProvider with ChangeNotifier {
); );
_contacts[contact.publicKeyHex] = updatedContact; _contacts[contact.publicKeyHex] = updatedContact;
_pendingAdverts.remove(contact.publicKeyHex);
debugPrint( debugPrint(
' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}', ' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}',
); );
@@ -495,7 +581,6 @@ class ContactsProvider with ChangeNotifier {
incomingContact: contact, incomingContact: contact,
existingContact: existingContact, existingContact: existingContact,
); );
_pendingAdverts.remove(contact.publicKeyHex);
} }
if (excluded > 0) { if (excluded > 0) {
debugPrint( debugPrint(
@@ -1095,7 +1180,7 @@ class ContactsProvider with ChangeNotifier {
} }
/// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80). /// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80).
/// Excludes self key and existing contacts. /// Excludes only self key; known contacts still keep a discovery entry.
bool addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) { bool addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) {
if (devicePublicKey != null && publicKey.matches(devicePublicKey)) { if (devicePublicKey != null && publicKey.matches(devicePublicKey)) {
return false; return false;
@@ -1104,12 +1189,6 @@ class ContactsProvider with ChangeNotifier {
final keyHex = publicKey final keyHex = publicKey
.map((b) => b.toRadixString(16).padLeft(2, '0')) .map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(''); .join('');
if (_contacts.containsKey(keyHex)) {
_pendingAdverts.remove(keyHex);
_persistPendingAdverts();
return false;
}
final existing = _pendingAdverts[keyHex]; final existing = _pendingAdverts[keyHex];
final now = DateTime.now(); final now = DateTime.now();
if (existing != null) { if (existing != null) {
@@ -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 /// Find contact by name
Contact? findContactByName(String name) { Contact? findContactByName(String name) {
for (final contact in _contacts.values) { for (final contact in _contacts.values) {
@@ -1220,6 +1392,45 @@ class ContactsProvider with ChangeNotifier {
await _storageService.saveContacts(_contactsForStorage()); await _storageService.saveContacts(_contactsForStorage());
} }
Future<void> 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<Contact> _contactsForStorage() { List<Contact> _contactsForStorage() {
// Don't persist the public channel pseudo-contact (all zeros key) // Don't persist the public channel pseudo-contact (all zeros key)
const publicChannelKey = const publicChannelKey =
@@ -1253,10 +1464,23 @@ class ContactsProvider with ChangeNotifier {
return { return {
'publicKey': base64Encode(advert.publicKey), 'publicKey': base64Encode(advert.publicKey),
'receivedAtMillis': advert.receivedAt.millisecondsSinceEpoch, '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, 'signedEncodedPathLen': advert.signedEncodedPathLen,
'paddedPathBytes': advert.paddedPathBytes == null 'paddedPathBytes': advert.paddedPathBytes == null
? null ? null
: base64Encode(advert.paddedPathBytes!), : 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( receivedAt: DateTime.fromMillisecondsSinceEpoch(
json['receivedAtMillis'] as int, 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?, signedEncodedPathLen: json['signedEncodedPathLen'] as int?,
paddedPathBytes: json['paddedPathBytes'] == null paddedPathBytes: json['paddedPathBytes'] == null
? null ? null
: Uint8List.fromList( : Uint8List.fromList(
base64Decode(json['paddedPathBytes'] as String), 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) { } catch (e) {
debugPrint( debugPrint(

View File

@@ -180,15 +180,23 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
bool _telemetryEnabled = false; bool _telemetryEnabled = false;
bool _repeatEnabled = false; bool _repeatEnabled = false;
bool _autoAddDiscoveredContactsEnabled = true; bool _autoAddDiscoveredContactsEnabled = true;
bool _autoAddUsersEnabled = true;
bool _autoAddRepeatersEnabled = true;
bool _autoAddRoomServersEnabled = true;
bool _autoAddSensorsEnabled = true;
bool _overwriteOldestAutoAddEnabled = false;
bool _showCustomRadioSettings = false; bool _showCustomRadioSettings = false;
bool _isSavingPublicInfo = false; bool _isSavingPublicInfo = false;
bool _isSavingRadioSettings = false; bool _isSavingRadioSettings = false;
bool _isSavingAutoDiscoverySettings = false;
bool _isClearingContacts = false; bool _isClearingContacts = false;
bool _isClearingChannels = false; bool _isClearingChannels = false;
bool _publicInfoSaved = false; bool _publicInfoSaved = false;
bool _radioSettingsSaved = false; bool _radioSettingsSaved = false;
bool _autoDiscoverySettingsSaved = false;
String? _publicInfoError; String? _publicInfoError;
String? _radioSettingsError; String? _radioSettingsError;
String? _autoDiscoverySettingsError;
String _selectedBandwidth = '62.5 kHz'; String _selectedBandwidth = '62.5 kHz';
int _selectedSpreadingFactor = 8; int _selectedSpreadingFactor = 8;
int _selectedCodingRate = 8; int _selectedCodingRate = 8;
@@ -267,6 +275,12 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
_repeatEnabled = deviceInfo.clientRepeat ?? false; _repeatEnabled = deviceInfo.clientRepeat ?? false;
_autoAddDiscoveredContactsEnabled = _autoAddDiscoveredContactsEnabled =
!(deviceInfo.manualAddContacts ?? false); !(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 // Fetch allowed repeat frequencies on open if device supports repeat mode
if (deviceInfo.clientRepeat != null && if (deviceInfo.clientRepeat != null &&
@@ -278,6 +292,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<ConnectionProvider>().getBatteryAndStorage(); context.read<ConnectionProvider>().getBatteryAndStorage();
context.read<ConnectionProvider>().getAutoaddConfig();
}); });
} }
@@ -390,6 +405,15 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
} }
} }
void _markAutoDiscoverySettingsDirty() {
if (_autoDiscoverySettingsSaved || _autoDiscoverySettingsError != null) {
setState(() {
_autoDiscoverySettingsSaved = false;
_autoDiscoverySettingsError = null;
});
}
}
Future<void> _savePublicInfo() async { Future<void> _savePublicInfo() async {
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final validator = ValidationService(); final validator = ValidationService();
@@ -401,7 +425,8 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
}); });
try { try {
final manualAddContacts = _autoAddDiscoveredContactsEnabled ? 0 : 1; final manualAddContacts =
(connectionProvider.deviceInfo.manualAddContacts ?? false) ? 1 : 0;
// Save name // Save name
if (_nameController.text.isNotEmpty) { if (_nameController.text.isNotEmpty) {
@@ -553,6 +578,49 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
} }
} }
Future<void> _saveAutoDiscoverySettings() async {
final connectionProvider = context.read<ConnectionProvider>();
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<void> _useCurrentLocation() async { Future<void> _useCurrentLocation() async {
try { try {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
@@ -997,9 +1065,10 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
_ConfigSectionCard( _ConfigSectionCard(
title: AppLocalizations.of(context)!.publicInfo, title: 'Auto discovery',
subtitle: 'Choose the name and location this device shares.', subtitle:
icon: Icons.public_rounded, 'Control how the radio auto-adds discovered nodes to its contacts table.',
icon: Icons.person_search_rounded,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -1007,9 +1076,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
icon: _autoAddDiscoveredContactsEnabled icon: _autoAddDiscoveredContactsEnabled
? Icons.person_add_alt_1 ? Icons.person_add_alt_1
: Icons.person_add_disabled, : Icons.person_add_disabled,
title: 'Auto-add discovered contacts', title: 'Enable automatic adding',
description: description:
'Control whether the device automatically stores newly discovered contacts.', 'Turn this off to keep discoveries manual-only on the radio.',
accentColor: _autoAddDiscoveredContactsEnabled accentColor: _autoAddDiscoveredContactsEnabled
? colorScheme.primary ? colorScheme.primary
: colorScheme.onSurfaceVariant, : colorScheme.onSurfaceVariant,
@@ -1018,13 +1087,148 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
_autoAddDiscoveredContactsEnabled = value; _autoAddDiscoveredContactsEnabled = value;
_publicInfoSaved = false; _markAutoDiscoverySettingsDirty();
_publicInfoError = null;
}); });
}, },
), ),
), ),
const SizedBox(height: 18), 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( _SettingHighlightCard(
icon: _telemetryEnabled icon: _telemetryEnabled
? Icons.travel_explore ? Icons.travel_explore

View File

@@ -1,11 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'dart:typed_data';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart'; import '../providers/contacts_provider.dart';
import '../services/mesh_map_nodes_service.dart'; import '../services/mesh_map_nodes_service.dart';
import '../widgets/compact_signal_indicator.dart' show SignalMetric;
enum _DiscoveryMenuAction { repeaters, sensors }
class DiscoveryScreen extends StatefulWidget { class DiscoveryScreen extends StatefulWidget {
const DiscoveryScreen({super.key}); const DiscoveryScreen({super.key});
@@ -15,7 +19,10 @@ class DiscoveryScreen extends StatefulWidget {
} }
class _DiscoveryScreenState extends State<DiscoveryScreen> { class _DiscoveryScreenState extends State<DiscoveryScreen> {
static const int _repeaterAdvertType = 2;
static const int _sensorAdvertType = 4;
final Set<String> _resolvingAdvertKeys = <String>{}; final Set<String> _resolvingAdvertKeys = <String>{};
final Set<int> _runningDiscoveryTypes = <int>{};
bool _isResolvingAll = false; bool _isResolvingAll = false;
late final Future<List<MeshMapNode>> _cachedNodesFuture; late final Future<List<MeshMapNode>> _cachedNodesFuture;
@@ -27,6 +34,85 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
); );
} }
Future<void> _handleMenuAction(_DiscoveryMenuAction action) async {
switch (action) {
case _DiscoveryMenuAction.repeaters:
await _discoverNodeType(_repeaterAdvertType);
break;
case _DiscoveryMenuAction.sensors:
await _discoverNodeType(_sensorAdvertType);
break;
}
}
Future<void> _clearAllDiscoveries() async {
final pendingCount = context.read<ContactsProvider>().pendingAdverts.length;
if (pendingCount == 0) {
return;
}
final confirmed = await showDialog<bool>(
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<ContactsProvider>().clearPendingAdverts();
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Cleared pending discoveries.')),
);
}
Future<void> _discoverNodeType(int advertType) async {
if (_runningDiscoveryTypes.contains(advertType)) return;
setState(() {
_runningDiscoveryTypes.add(advertType);
});
try {
await context.read<ConnectionProvider>().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<void> _resolveAdvert(PendingAdvert advert) async { Future<void> _resolveAdvert(PendingAdvert advert) async {
final keyHex = advert.publicKeyHex; final keyHex = advert.publicKeyHex;
if (_resolvingAdvertKeys.contains(keyHex)) return; if (_resolvingAdvertKeys.contains(keyHex)) return;
@@ -36,7 +122,22 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
}); });
try { try {
await context.read<ConnectionProvider>().getContact(advert.publicKey); final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>();
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 { } finally {
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -76,11 +177,39 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
return l10n.daysAgo(diff.inDays); 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( String _displayNameForAdvert(
PendingAdvert advert, PendingAdvert advert,
ContactsProvider contactsProvider, ContactsProvider contactsProvider,
List<MeshMapNode> cachedNodes, List<MeshMapNode> cachedNodes,
) { ) {
final advertisedName = advert.advName?.trim();
if (advertisedName != null && advertisedName.isNotEmpty) {
return advertisedName;
}
Contact? existingMatch; Contact? existingMatch;
for (final contact in contactsProvider.contacts) { for (final contact in contactsProvider.contacts) {
if (contact.publicKeyHex == advert.publicKeyHex) { if (contact.publicKeyHex == advert.publicKeyHex) {
@@ -106,12 +235,144 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
return advert.shortDisplayKey; 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<MeshMapNode> 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Discovery')), appBar: AppBar(
title: const Text('Discovery'),
actions: [
Consumer<ConnectionProvider>(
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<List<MeshMapNode>>( body: FutureBuilder<List<MeshMapNode>>(
future: _cachedNodesFuture, future: _cachedNodesFuture,
builder: (context, nodesSnapshot) => builder: (context, nodesSnapshot) =>
@@ -121,65 +382,98 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
final isConnected = connectionProvider.deviceInfo.isConnected; final isConnected = connectionProvider.deviceInfo.isConnected;
final cachedNodes = nodesSnapshot.data ?? const <MeshMapNode>[]; final cachedNodes = nodesSnapshot.data ?? const <MeshMapNode>[];
if (pendingAdverts.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.person_search_outlined,
size: 64,
color: Theme.of(context).disabledColor,
),
const SizedBox(height: 16),
Text(
'No pending discoveries',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Unknown adverts will appear here until you choose to resolve them.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
);
}
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
Card( Card(
child: ListTile( child: Padding(
leading: const Icon(Icons.person_search), padding: const EdgeInsets.all(16),
title: Text( child: Column(
'Pending discoveries (${pendingAdverts.length})', crossAxisAlignment: CrossAxisAlignment.start,
), children: [
subtitle: const Text( Row(
'Resolve entries manually so they do not auto-populate contacts.', crossAxisAlignment: CrossAxisAlignment.center,
), children: [
trailing: FilledButton.icon( const Icon(Icons.person_search),
onPressed: isConnected && !_isResolvingAll const SizedBox(width: 12),
? () => _resolveAll(pendingAdverts) Expanded(
: null, child: Text(
icon: _isResolvingAll 'Pending discoveries (${pendingAdverts.length})',
? const SizedBox( style: Theme.of(context).textTheme.titleMedium,
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
), ),
) ),
: 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) { ...pendingAdverts.map((advert) {
final isResolving = _resolvingAdvertKeys.contains( final isResolving = _resolvingAdvertKeys.contains(
advert.publicKeyHex, advert.publicKeyHex,
@@ -189,35 +483,92 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
contactsProvider, contactsProvider,
cachedNodes, 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 = <String>[
'${l10n.publicKey}: ${advert.shortDisplayKey}',
];
final summaryParts = <String>[];
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( return Card(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
child: ListTile( child: Padding(
leading: const CircleAvatar( padding: const EdgeInsets.all(14),
child: Icon(Icons.campaign_outlined), child: Column(
), crossAxisAlignment: CrossAxisAlignment.start,
title: Text( children: [
displayName, Row(
style: const TextStyle(fontWeight: FontWeight.bold), crossAxisAlignment: CrossAxisAlignment.center,
), children: [
subtitle: Text( CircleAvatar(
'${l10n.publicKey}: ${advert.shortDisplayKey}\n' child: Icon(_iconForAdvert(advert)),
'${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}',
),
trailing: isResolving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
), ),
) const SizedBox(width: 12),
: IconButton( Expanded(
icon: const Icon(Icons.person_add_alt_1), child: _buildAdvertTitle(
tooltip: 'Resolve contact', context,
onPressed: isConnected displayName: displayName,
? () => _resolveAdvert(advert) subtitle: typeLabel,
: null, ),
), ),
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<DiscoveryScreen> {
), ),
); );
} }
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),
),
),
],
],
);
}
} }

View File

@@ -11,6 +11,8 @@ import '../services/live_traffic_summary.dart';
import '../services/location_tracking_service.dart'; import '../services/location_tracking_service.dart';
import '../services/route_hash_preferences.dart'; import '../services/route_hash_preferences.dart';
import '../utils/log_rx_route_decoder.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'; import 'packet_log_screen.dart';
T? _maybeProvider<T>(BuildContext context) { T? _maybeProvider<T>(BuildContext context) {
@@ -650,110 +652,101 @@ class _LiveTrafficCard extends StatelessWidget {
final rxInfo = log.logRxDataInfo; final rxInfo = log.logRxDataInfo;
final originDistance = _originDistanceLabel(context, entry); final originDistance = _originDistanceLabel(context, entry);
final packetDetails = _LiveTrafficPacketDetails.fromEntry(entry); final packetDetails = _LiveTrafficPacketDetails.fromEntry(entry);
final signalMetric = _SignalMetric.fromRxInfo(rxInfo); final signalMetric = SignalMetric.fromRxInfo(rxInfo);
return Container( return Material(
padding: const EdgeInsets.all(14), color: Colors.transparent,
decoration: BoxDecoration( child: InkWell(
color: Theme.of(context).colorScheme.surfaceContainerLow, onTap: () => _showPacketBytesSheet(context, log.rawData),
onLongPress: () => _showTraceSheet(context, entry),
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
border: Border.all(color: accent.withValues(alpha: 0.25)), child: Container(
), padding: const EdgeInsets.all(14),
child: Column( decoration: BoxDecoration(
crossAxisAlignment: CrossAxisAlignment.start, color: Theme.of(context).colorScheme.surfaceContainerLow,
children: [ borderRadius: BorderRadius.circular(18),
Row( border: Border.all(color: accent.withValues(alpha: 0.25)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( Row(
padding: const EdgeInsets.symmetric( children: [
horizontal: 10, Container(
vertical: 6, padding: const EdgeInsets.symmetric(
), horizontal: 10,
decoration: BoxDecoration( vertical: 6,
color: accent.withValues(alpha: 0.14), ),
borderRadius: BorderRadius.circular(999), decoration: BoxDecoration(
), color: accent.withValues(alpha: 0.14),
child: Text( borderRadius: BorderRadius.circular(999),
isRx ? 'RX' : 'TX', ),
style: TextStyle( child: Text(
color: accent, isRx ? 'RX' : 'TX',
fontWeight: FontWeight.w800, style: TextStyle(
fontSize: 12, 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,
), ),
), ),
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(width: 8),
], Expanded(
), child: Column(
const SizedBox(height: 10), crossAxisAlignment: CrossAxisAlignment.start,
_PacketInfoLine( children: [
text: Text(
'${_formatClock(log.timestamp)} • Size: ${log.rawData.length} bytes', packetDetails.title,
), style: const TextStyle(
_PacketInfoLine(text: 'Hash: ${packetDetails.packetHashHex}'), fontSize: 16,
if (packetDetails.pathLine != null) fontWeight: FontWeight.w700,
_PacketInfoLine(text: packetDetails.pathLine!), ),
if (packetDetails.pathHashLine != null) ),
_PacketInfoLine(text: packetDetails.pathHashLine!), if (entry.payloadMeaning != null)
if (packetDetails.endpointLine != null) Text(
_PacketInfoLine(text: packetDetails.endpointLine!), entry.payloadMeaning!,
const SizedBox(height: 10), maxLines: 1,
Wrap( overflow: TextOverflow.ellipsis,
spacing: 8, style: TextStyle(
runSpacing: 8, fontSize: 11,
children: [ color: Theme.of(
_PacketMetaChip( context,
label: '${log.rawData.length} bytes', ).colorScheme.onSurfaceVariant,
onTap: () => _showPacketBytesSheet(context, log.rawData), ),
),
],
),
),
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 SizedBox(height: 10),
const _PacketMetaChip(label: 'MULTI-HOP', emphasized: true), _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) if (originDistance != null)
_PacketMetaChip(label: 'Origin $originDistance'), _PacketInfoLine(text: 'Origin: $originDistance'),
if (rxInfo?.rssiDbm != null)
_PacketMetaChip(label: 'RSSI ${rxInfo!.rssiDbm} dBm'),
if (rxInfo?.snrDb != null)
_PacketMetaChip(
label: 'SNR ${rxInfo!.snrDb!.toStringAsFixed(1)} dB',
),
], ],
), ),
], ),
), ),
); );
} }
@@ -771,38 +764,6 @@ class _LiveTrafficCard extends StatelessWidget {
return '$hour:$minute:$second'; 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<ContactsProvider>(context);
final connectionProvider = _maybeProvider<ConnectionProvider>(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 <Contact>[],
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( static String? _originDistanceLabel(
BuildContext context, BuildContext context,
LiveTrafficEntry entry, LiveTrafficEntry entry,
@@ -962,15 +923,31 @@ class _LiveTrafficCard extends StatelessWidget {
); );
} }
static String _compactNodeLabel(ResolvedNodeHash node) { static Future<void> _showTraceSheet(
if (node.isOwnNode) { BuildContext context,
return node.label; LiveTrafficEntry entry,
) {
final route = entry.route;
if (route == null || route.pathBytes.isEmpty) {
return Future.value();
} }
if (node.matchCount > 0) { return showModalBottomSheet<void>(
return node.label; context: context,
} isScrollControlled: true,
return node.hexLabel; 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 { 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 { class _GeoPoint {
final double latitude; final double latitude;
@@ -1227,44 +1114,6 @@ class _GeoPoint {
const _GeoPoint(this.latitude, this.longitude); 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) { void openLiveTrafficScreen(BuildContext context, ConnectionProvider provider) {
Navigator.push( Navigator.push(
context, context,

View File

@@ -519,6 +519,7 @@ class _MessagesTabState extends State<MessagesTab> {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
Contact? senderContact; Contact? senderContact;
String? senderDisplayName;
String destinationType; String destinationType;
Contact? recipient; Contact? recipient;
@@ -543,28 +544,48 @@ class _MessagesTabState extends State<MessagesTab> {
if (senderPrefix != null && senderPrefix.length >= 6) { if (senderPrefix != null && senderPrefix.length >= 6) {
senderContact = contactsProvider.findContactByPrefix(senderPrefix); senderContact = contactsProvider.findContactByPrefix(senderPrefix);
} }
senderDisplayName =
senderContact?.displayName ?? message.senderName?.trim();
} else { } else {
final senderPrefix = message.senderPublicKeyPrefix; final roomRecipient = message.recipientPublicKey == null
if (senderPrefix == null || senderPrefix.length < 6) { ? null
ToastLogger.error(context, l10n.cannotReplySenderMissing); : contactsProvider.findContactByKey(message.recipientPublicKey!);
return;
}
recipient = contactsProvider.findContactByPrefix(senderPrefix); if (roomRecipient?.isRoom == true) {
if (recipient == null) { destinationType = MessageDestinationPreferences.destinationTypeRoom;
ToastLogger.error(context, l10n.cannotReplyContactNotFound); recipient = roomRecipient;
return;
}
destinationType = recipient.isRoom final senderPrefix = message.senderPublicKeyPrefix;
? MessageDestinationPreferences.destinationTypeRoom if (senderPrefix != null && senderPrefix.length >= 6) {
: MessageDestinationPreferences.destinationTypeContact; 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); await _onRecipientSelected(destinationType, recipient);
if (!mounted) return; if (!mounted) return;
if (message.isChannelMessage && senderContact != null) { if ((message.isChannelMessage || recipient?.isRoom == true) &&
_insertReplyMention(senderContact.displayName); senderDisplayName != null &&
senderDisplayName.isNotEmpty) {
_insertReplyMention(senderDisplayName);
} }
_focusNode.requestFocus(); _focusNode.requestFocus();
} }

View File

@@ -26,6 +26,7 @@ class NotificationService {
bool _updateNotificationsEnabled = true; bool _updateNotificationsEnabled = true;
bool _muteForegroundNotifications = true; bool _muteForegroundNotifications = true;
AppLifecycleState _lifecycleState = AppLifecycleState.resumed; AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
String? _launchPayload;
// Notification IDs // Notification IDs
static const int _sarNotificationId = 1000; static const int _sarNotificationId = 1000;
@@ -103,6 +104,12 @@ class NotificationService {
onDidReceiveNotificationResponse: _onNotificationResponse, onDidReceiveNotificationResponse: _onNotificationResponse,
); );
final launchDetails = await _notificationsPlugin
.getNotificationAppLaunchDetails();
if (launchDetails?.didNotificationLaunchApp ?? false) {
_launchPayload = launchDetails?.notificationResponse?.payload;
}
// Request permissions // Request permissions
await _requestPermissions(); await _requestPermissions();
await _loadPreferences(); await _loadPreferences();
@@ -295,6 +302,12 @@ class NotificationService {
} }
} }
String? consumeLaunchPayload() {
final payload = _launchPayload;
_launchPayload = null;
return payload;
}
/// Show urgent notification for SAR marker /// Show urgent notification for SAR marker
Future<void> showSarNotification({ Future<void> showSarNotification({
required SarMarkerType type, required SarMarkerType type,
@@ -837,6 +850,7 @@ class NotificationService {
Future<bool> showContactDiscoveredNotification({ Future<bool> showContactDiscoveredNotification({
required String contactKey, required String contactKey,
String? contactName,
}) async { }) async {
if (!_isInitialized) return false; if (!_isInitialized) return false;
if (!_permissionGranted) return false; if (!_permissionGranted) return false;
@@ -847,7 +861,10 @@ class NotificationService {
? contactKey.substring(0, 12).toUpperCase() ? contactKey.substring(0, 12).toUpperCase()
: contactKey.toUpperCase(); : contactKey.toUpperCase();
final title = 'New contact discovered'; 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 = final notificationId =
_discoveryNotificationId + ((contactKey.hashCode & 0x7fffffff) % 1000); _discoveryNotificationId + ((contactKey.hashCode & 0x7fffffff) % 1000);

View File

@@ -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,
),
),
],
);
}
}

View File

@@ -17,9 +17,24 @@ import '../../utils/log_rx_route_decoder.dart';
import '../../utils/trace_node_resolver.dart'; import '../../utils/trace_node_resolver.dart';
class MessageTraceSheet extends StatefulWidget { class MessageTraceSheet extends StatefulWidget {
final Message message; final Message? message;
final List<int>? 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<int> packetPath,
this.descriptionOverride,
this.noRelayMatchTextOverride,
}) : message = null,
packetPathOverride = packetPath;
@override @override
State<MessageTraceSheet> createState() => _MessageTraceSheetState(); State<MessageTraceSheet> createState() => _MessageTraceSheetState();
@@ -40,20 +55,27 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
final messagesProvider = context.read<MessagesProvider>(); final messagesProvider = context.read<MessagesProvider>();
final preferredHashSize = await RouteHashPreferences.getHashSize(); final preferredHashSize = await RouteHashPreferences.getHashSize();
final storedPath = messagesProvider List<int>? packetPath = widget.packetPathOverride;
.getMessageReceptionDetails(widget.message.id) String? senderPrefix;
?.pathBytes; String? recipientPrefix;
final packetPath = (storedPath != null && storedPath.isNotEmpty)
? storedPath if (widget.message case final message?) {
: _extractPathFromPacketLogs( final storedPath = messagesProvider
logs: connectionProvider.bleService.packetLogs, .getMessageReceptionDetails(message.id)
message: widget.message, ?.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 localNodes = _localNodesFromContacts(contactsProvider);
final localPublicKeys = localNodes.map((node) => node.publicKey).toSet(); final localPublicKeys = localNodes.map((node) => node.publicKey).toSet();
var trace = _buildTraceResult( var trace = _buildTraceResult(
@@ -66,7 +88,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
); );
if (_isCompleteTrace( if (_isCompleteTrace(
trace, trace,
expectedRelayCount: math.max(0, widget.message.pathLen), expectedRelayCount: widget.message == null
? 0
: math.max(0, widget.message!.pathLen),
)) { )) {
return trace; return trace;
} }
@@ -153,9 +177,10 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text( child: Text(
trace.mode == TraceMode.packetPath widget.descriptionOverride ??
? 'Relay path from packet path bytes' (trace.mode == TraceMode.packetPath
: 'Relay path inferred from hop count (${widget.message.pathLen})', ? 'Relay path from packet path bytes'
: 'Relay path inferred from hop count (${widget.message!.pathLen})'),
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
), ),
@@ -257,13 +282,14 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
), ),
), ),
if (routeEntries.isEmpty) if (routeEntries.isEmpty)
const Padding( Padding(
padding: EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 16, horizontal: 16,
vertical: 8, vertical: 8,
), ),
child: Text( 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( ...routeEntries.asMap().entries.map(
@@ -345,6 +371,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
if (concrete.isEmpty) return const []; if (concrete.isEmpty) return const [];
if (trace.mode == TraceMode.packetPath) { if (trace.mode == TraceMode.packetPath) {
if (widget.message == null) {
return concrete;
}
if (concrete.length <= 1) return const []; if (concrete.length <= 1) return const [];
return concrete.sublist(1); return concrete.sublist(1);
} }
@@ -488,7 +517,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
nodes: nodes, nodes: nodes,
sender: senderNode.node, sender: senderNode.node,
recipient: recipientNode.node, recipient: recipientNode.node,
relayCount: math.max(0, widget.message.pathLen), relayCount: math.max(0, widget.message!.pathLen),
); );
final matchedPathNodes = <ResolvedTraceNode>[ final matchedPathNodes = <ResolvedTraceNode>[
if (senderNode.node != null) senderNode, if (senderNode.node != null) senderNode,

View File

@@ -795,8 +795,8 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "."
ref: "55e303a" ref: "813f5b3"
resolved-ref: "55e303aa71d9fd847cedd11bc1243504fbe363e0" resolved-ref: "813f5b3e0b9d2ea6b85a428be443bf5e6a38c6c5"
url: "https://github.com/dz0ny/meshcore_client.git" url: "https://github.com/dz0ny/meshcore_client.git"
source: git source: git
version: "0.1.0" version: "0.1.0"

View File

@@ -44,7 +44,7 @@ dependencies:
meshcore_client: meshcore_client:
git: git:
url: https://github.com/dz0ny/meshcore_client.git url: https://github.com/dz0ny/meshcore_client.git
ref: "55e303a" ref: "813f5b3"
# Codec2 ultra-low-bitrate speech codec (FFI plugin) # Codec2 ultra-low-bitrate speech codec (FFI plugin)
codec2_flutter: codec2_flutter: