mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
feat: Finish discovery flow
ref:
This commit is contained in:
@@ -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}',
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
try {
|
||||||
|
await _activeService.getAutoaddConfig();
|
||||||
|
_supportsAutoaddConfig = true;
|
||||||
|
} catch (e) {
|
||||||
|
if (_isUnsupportedAutoaddConfigError(e)) {
|
||||||
|
_supportsAutoaddConfig = false;
|
||||||
|
} else {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
await _activeService.getAllowedRepeatFreq();
|
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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,12 +382,78 @@ 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 ListView(
|
||||||
return Center(
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.person_search),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Pending discoveries (${pendingAdverts.length})',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const 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: 16),
|
||||||
|
if (pendingAdverts.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 48),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.person_search_outlined,
|
Icons.person_search_outlined,
|
||||||
@@ -147,39 +474,6 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ListView(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
children: [
|
|
||||||
Card(
|
|
||||||
child: ListTile(
|
|
||||||
leading: const Icon(Icons.person_search),
|
|
||||||
title: Text(
|
|
||||||
'Pending discoveries (${pendingAdverts.length})',
|
|
||||||
),
|
|
||||||
subtitle: const Text(
|
|
||||||
'Resolve entries manually so they do not auto-populate contacts.',
|
|
||||||
),
|
|
||||||
trailing: FilledButton.icon(
|
|
||||||
onPressed: isConnected && !_isResolvingAll
|
|
||||||
? () => _resolveAll(pendingAdverts)
|
|
||||||
: null,
|
|
||||||
icon: _isResolvingAll
|
|
||||||
? const SizedBox(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.download_for_offline_outlined),
|
|
||||||
label: const Text('Resolve all'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
...pendingAdverts.map((advert) {
|
...pendingAdverts.map((advert) {
|
||||||
final isResolving = _resolvingAdvertKeys.contains(
|
final isResolving = _resolvingAdvertKeys.contains(
|
||||||
advert.publicKeyHex,
|
advert.publicKeyHex,
|
||||||
@@ -189,21 +483,66 @@ 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,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
child: Icon(_iconForAdvert(advert)),
|
||||||
),
|
),
|
||||||
title: Text(
|
const SizedBox(width: 12),
|
||||||
displayName,
|
Expanded(
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
child: _buildAdvertTitle(
|
||||||
|
context,
|
||||||
|
displayName: displayName,
|
||||||
|
subtitle: typeLabel,
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
|
||||||
'${l10n.publicKey}: ${advert.shortDisplayKey}\n'
|
|
||||||
'${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}',
|
|
||||||
),
|
),
|
||||||
trailing: isResolving
|
if (downMetric != null || upMetric != null) ...[
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
_buildSignalSummary(
|
||||||
|
context,
|
||||||
|
downMetric: downMetric,
|
||||||
|
upMetric: upMetric,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
isResolving
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 18,
|
width: 18,
|
||||||
height: 18,
|
height: 18,
|
||||||
@@ -212,12 +551,24 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
: IconButton(
|
: IconButton(
|
||||||
icon: const Icon(Icons.person_add_alt_1),
|
visualDensity: VisualDensity.compact,
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.person_add_alt_1,
|
||||||
|
),
|
||||||
tooltip: 'Resolve contact',
|
tooltip: 'Resolve contact',
|
||||||
onPressed: isConnected
|
onPressed: isConnected
|
||||||
? () => _resolveAdvert(advert)
|
? () => _resolveAdvert(advert)
|
||||||
: null,
|
: 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,9 +652,15 @@ 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(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => _showPacketBytesSheet(context, log.rawData),
|
||||||
|
onLongPress: () => _showTraceSheet(context, entry),
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
child: Container(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||||
@@ -701,7 +709,9 @@ class _LiveTrafficCard extends StatelessWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -709,7 +719,7 @@ class _LiveTrafficCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
if (signalMetric != null) ...[
|
if (signalMetric != null) ...[
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
_CompactSignalIndicator(metric: signalMetric),
|
CompactSignalIndicator(metric: signalMetric),
|
||||||
] else
|
] else
|
||||||
Text(
|
Text(
|
||||||
_timeAgo(log.timestamp, now),
|
_timeAgo(log.timestamp, now),
|
||||||
@@ -732,28 +742,11 @@ class _LiveTrafficCard extends StatelessWidget {
|
|||||||
_PacketInfoLine(text: packetDetails.pathHashLine!),
|
_PacketInfoLine(text: packetDetails.pathHashLine!),
|
||||||
if (packetDetails.endpointLine != null)
|
if (packetDetails.endpointLine != null)
|
||||||
_PacketInfoLine(text: packetDetails.endpointLine!),
|
_PacketInfoLine(text: packetDetails.endpointLine!),
|
||||||
const SizedBox(height: 10),
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: [
|
|
||||||
_PacketMetaChip(
|
|
||||||
label: '${log.rawData.length} bytes',
|
|
||||||
onTap: () => _showPacketBytesSheet(context, log.rawData),
|
|
||||||
),
|
|
||||||
if (entry.isMultiHop)
|
|
||||||
const _PacketMetaChip(label: 'MULTI-HOP', emphasized: true),
|
|
||||||
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,
|
||||||
|
|||||||
@@ -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,6 +544,23 @@ 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 {
|
||||||
|
final roomRecipient = message.recipientPublicKey == null
|
||||||
|
? null
|
||||||
|
: contactsProvider.findContactByKey(message.recipientPublicKey!);
|
||||||
|
|
||||||
|
if (roomRecipient?.isRoom == true) {
|
||||||
|
destinationType = MessageDestinationPreferences.destinationTypeRoom;
|
||||||
|
recipient = roomRecipient;
|
||||||
|
|
||||||
|
final senderPrefix = message.senderPublicKeyPrefix;
|
||||||
|
if (senderPrefix != null && senderPrefix.length >= 6) {
|
||||||
|
senderContact = contactsProvider.findContactByPrefix(senderPrefix);
|
||||||
|
}
|
||||||
|
senderDisplayName =
|
||||||
|
senderContact?.displayName ?? message.senderName?.trim();
|
||||||
} else {
|
} else {
|
||||||
final senderPrefix = message.senderPublicKeyPrefix;
|
final senderPrefix = message.senderPublicKeyPrefix;
|
||||||
if (senderPrefix == null || senderPrefix.length < 6) {
|
if (senderPrefix == null || senderPrefix.length < 6) {
|
||||||
@@ -560,11 +578,14 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
? MessageDestinationPreferences.destinationTypeRoom
|
? MessageDestinationPreferences.destinationTypeRoom
|
||||||
: MessageDestinationPreferences.destinationTypeContact;
|
: 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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
106
lib/widgets/compact_signal_indicator.dart
Normal file
106
lib/widgets/compact_signal_indicator.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
List<int>? packetPath = widget.packetPathOverride;
|
||||||
|
String? senderPrefix;
|
||||||
|
String? recipientPrefix;
|
||||||
|
|
||||||
|
if (widget.message case final message?) {
|
||||||
final storedPath = messagesProvider
|
final storedPath = messagesProvider
|
||||||
.getMessageReceptionDetails(widget.message.id)
|
.getMessageReceptionDetails(message.id)
|
||||||
?.pathBytes;
|
?.pathBytes;
|
||||||
final packetPath = (storedPath != null && storedPath.isNotEmpty)
|
packetPath = (storedPath != null && storedPath.isNotEmpty)
|
||||||
? storedPath
|
? storedPath
|
||||||
: _extractPathFromPacketLogs(
|
: _extractPathFromPacketLogs(
|
||||||
logs: connectionProvider.bleService.packetLogs,
|
logs: connectionProvider.bleService.packetLogs,
|
||||||
message: widget.message,
|
message: message,
|
||||||
);
|
);
|
||||||
|
|
||||||
final senderPrefix = _toPrefixHex(widget.message.senderPublicKeyPrefix);
|
senderPrefix = _toPrefixHex(message.senderPublicKeyPrefix);
|
||||||
final recipientPrefix = widget.message.recipientPublicKey != null
|
recipientPrefix = message.recipientPublicKey != null
|
||||||
? _toPrefixHex(widget.message.recipientPublicKey)
|
? _toPrefixHex(message.recipientPublicKey)
|
||||||
: _toPrefixHex(connectionProvider.deviceInfo.publicKey);
|
: _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 ??
|
||||||
|
(trace.mode == TraceMode.packetPath
|
||||||
? 'Relay path from packet path bytes'
|
? 'Relay path from packet path bytes'
|
||||||
: 'Relay path inferred from hop count (${widget.message.pathLen})',
|
: 'Relay path inferred from hop count (${widget.message!.pathLen})'),
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -257,12 +282,13 @@ 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(
|
||||||
|
widget.noRelayMatchTextOverride ??
|
||||||
'No named nodes could be matched for this trace.',
|
'No named nodes could be matched for this trace.',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user