mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-09 18:22:48 +10:00
feat: add contact UI helpers and path editor for routing management
- Implemented contactTypeIcon and contactTypeColor functions for better UI representation of contact types. - Created colorForName and firstCharacterOrEmoji functions to enhance contact display. - Developed PathEditorSheet widget for managing contact paths with a user-friendly interface. - Introduced RoutingSheet for managing contact routing modes and displaying path history. - Added a script for generating proof of concept (PoC) payloads for clipboard contact import validation.
This commit is contained in:
@@ -160,6 +160,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
{}; // contactPubKeyHex -> Set of "targetHash_emoji"
|
||||
|
||||
StreamSubscription<List<ScanResult>>? _scanSubscription;
|
||||
StreamSubscription<bool>? _isScanningSubscription;
|
||||
StreamSubscription<BluetoothConnectionState>? _connectionSubscription;
|
||||
StreamSubscription<List<int>>? _notifySubscription;
|
||||
Timer? _notifyListenersTimer;
|
||||
@@ -507,10 +508,22 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
if (messages == null) return;
|
||||
final removed = messages.remove(message);
|
||||
if (!removed) return;
|
||||
_retryService?.untrack(message.messageId);
|
||||
await _messageStore.saveMessages(contactKeyHex, messages);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> resendMessage(Contact contact, Message message) async {
|
||||
await deleteMessage(message);
|
||||
await sendMessage(
|
||||
contact,
|
||||
message.text,
|
||||
originalText: message.originalText,
|
||||
translatedLanguageCode: message.translatedLanguageCode,
|
||||
translationModelId: message.translationModelId,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadMessagesForContact(String contactKeyHex) async {
|
||||
if (_loadedConversationKeys.contains(contactKeyHex)) return;
|
||||
_loadedConversationKeys.add(contactKeyHex);
|
||||
@@ -1356,6 +1369,21 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
}) async {
|
||||
if (_state == MeshCoreConnectionState.scanning) return;
|
||||
|
||||
// A BLE scan must never disturb an active (or in-progress) non-BLE
|
||||
// connection. The connection state enum is shared across transports, so
|
||||
// entering the `scanning` state while connected over TCP/USB would clobber
|
||||
// the live `connected` state and later reset it to `disconnected`.
|
||||
if (_state != MeshCoreConnectionState.disconnected ||
|
||||
_tcpConnector.isConnected ||
|
||||
_usbManager.isConnected) {
|
||||
_appDebugLogService?.warn(
|
||||
'startScan ignored: not idle (state=$_state, '
|
||||
'tcp=${_tcpConnector.isConnected}, usb=${_usbManager.isConnected})',
|
||||
tag: 'BLE Scan',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_scanResults.clear();
|
||||
_linuxSystemScanResults.clear();
|
||||
_setState(MeshCoreConnectionState.scanning);
|
||||
@@ -1409,20 +1437,40 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
});
|
||||
|
||||
try {
|
||||
// Filter by the Nordic UART Service UUID rather than by advertised
|
||||
// name. All MeshCore-compatible firmware (ESP32 + nRF52) advertises this
|
||||
// service UUID, so this matches every device regardless of the name it
|
||||
// chooses to advertise (e.g. community forks like the M5 Cardputer that
|
||||
// do not use a "MeshCore-" name prefix). This mirrors how the official
|
||||
// app discovers devices. Note: on Android `withKeywords` cannot be
|
||||
// combined with any other filter, which is why name keywords are not
|
||||
// used here.
|
||||
await FlutterBluePlus.startScan(
|
||||
withKeywords: MeshCoreUuids.deviceNamePrefixes,
|
||||
withServices: [Guid(MeshCoreUuids.service)],
|
||||
webOptionalServices: [Guid(MeshCoreUuids.service)],
|
||||
timeout: timeout,
|
||||
androidScanMode: AndroidScanMode.lowLatency,
|
||||
);
|
||||
} catch (error) {
|
||||
_appDebugLogService?.warn('Scan/picker failure: $error', tag: 'BLE Scan');
|
||||
_setState(MeshCoreConnectionState.disconnected);
|
||||
await stopScan();
|
||||
rethrow;
|
||||
}
|
||||
|
||||
await Future.delayed(timeout);
|
||||
await stopScan();
|
||||
// Reset our shared state when the native scan ends — whether it was stopped
|
||||
// by the user (stopScan), by the platform timeout, or by Bluetooth turning
|
||||
// off. This replaces a blocking `Future.delayed(timeout)` tail that kept
|
||||
// startScan() pending for the whole timeout and made Stop appear ineffective.
|
||||
// `isScanning` is a re-emit stream that replays its latest value on listen,
|
||||
// so skip(1) to ignore that and only react to a genuine transition to false.
|
||||
await _isScanningSubscription?.cancel();
|
||||
_isScanningSubscription = FlutterBluePlus.isScanning.skip(1).listen((
|
||||
scanning,
|
||||
) {
|
||||
if (!scanning && _state == MeshCoreConnectionState.scanning) {
|
||||
unawaited(stopScan());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadLinuxSystemDevicesForScan() async {
|
||||
@@ -1430,15 +1478,13 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
final systemDevices = await FlutterBluePlus.systemDevices([
|
||||
Guid(MeshCoreUuids.service),
|
||||
]);
|
||||
// systemDevices is already filtered by the NUS service UUID above, so no
|
||||
// additional name-prefix filtering is applied here. This keeps Linux
|
||||
// discovery name-agnostic and consistent with the main scan path.
|
||||
_linuxSystemScanResults
|
||||
..clear()
|
||||
..addAll(
|
||||
systemDevices
|
||||
.where(
|
||||
(device) => MeshCoreUuids.deviceNamePrefixes.any(
|
||||
device.platformName.startsWith,
|
||||
),
|
||||
)
|
||||
.map(
|
||||
(device) => ScanResult(
|
||||
device: device,
|
||||
@@ -1499,9 +1545,17 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
}
|
||||
await _scanSubscription?.cancel();
|
||||
_scanSubscription = null;
|
||||
await _isScanningSubscription?.cancel();
|
||||
_isScanningSubscription = null;
|
||||
|
||||
if (_state == MeshCoreConnectionState.scanning) {
|
||||
_setState(MeshCoreConnectionState.disconnected);
|
||||
// Restore to `connected` if a non-BLE transport is still live, so a stray
|
||||
// scan can never tear down the reported connection state. Normally there
|
||||
// is no live transport here and we fall through to `disconnected`.
|
||||
final restored = (_tcpConnector.isConnected || _usbManager.isConnected)
|
||||
? MeshCoreConnectionState.connected
|
||||
: MeshCoreConnectionState.disconnected;
|
||||
_setState(restored);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1743,6 +1797,17 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
activeTransport == MeshCoreTransportType.tcp;
|
||||
}
|
||||
|
||||
/// Fast (non-timeout) connect failures are usually a stale link left over
|
||||
/// from a previous session and recover on an immediate retry. Timeouts mean
|
||||
/// the device is likely off or out of range, so retrying would only delay
|
||||
/// genuine failure feedback.
|
||||
@visibleForTesting
|
||||
static bool shouldRetryBleConnectAfterError(String errorText) {
|
||||
final lowerErrorText = errorText.toLowerCase();
|
||||
return !lowerErrorText.contains('timed out') &&
|
||||
!lowerErrorText.contains('timeout');
|
||||
}
|
||||
|
||||
Future<void> connect(
|
||||
BluetoothDevice device, {
|
||||
String? displayName,
|
||||
@@ -1911,18 +1976,71 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await device.connect(
|
||||
Future<void> attemptConnect() {
|
||||
return device.connect(
|
||||
timeout: connectTimeout,
|
||||
mtu: null,
|
||||
license: License.free,
|
||||
);
|
||||
}
|
||||
|
||||
// A previous app session (e.g. killed from the iOS app switcher) can
|
||||
// leave the OS holding a stale link to the peripheral. Clear it before
|
||||
// connecting so the fresh attempt doesn't race the stale handle.
|
||||
if (!PlatformInfo.isWeb && device.isConnected) {
|
||||
_appDebugLogService?.warn(
|
||||
'Device reports an existing connection before connect; clearing stale link',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
try {
|
||||
await device.disconnect(queue: false);
|
||||
} catch (cleanupError) {
|
||||
_appDebugLogService?.warn(
|
||||
'Stale-link cleanup disconnect failed (continuing): $cleanupError',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await attemptConnect();
|
||||
} catch (error) {
|
||||
_appDebugLogService?.error(
|
||||
'device.connect() failure: $error',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
rethrow;
|
||||
if (PlatformInfo.isWeb ||
|
||||
!shouldRetryBleConnectAfterError(error.toString())) {
|
||||
rethrow;
|
||||
}
|
||||
// Fast (non-timeout) failures are usually a stale connection left by
|
||||
// a previous session; clean up and retry once before surfacing.
|
||||
_appDebugLogService?.warn(
|
||||
'Retrying connect once after clearing possible stale connection',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
try {
|
||||
await device.disconnect(queue: false);
|
||||
} catch (cleanupError) {
|
||||
_appDebugLogService?.warn(
|
||||
'Pre-retry cleanup disconnect failed (continuing): $cleanupError',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||
try {
|
||||
await attemptConnect();
|
||||
_appDebugLogService?.info(
|
||||
'Retry connect succeeded after stale-connection cleanup',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
} catch (retryError) {
|
||||
_appDebugLogService?.error(
|
||||
'device.connect() retry failure: $retryError',
|
||||
tag: 'BLE Connect',
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3237,6 +3355,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
await sendFrame(buildRemoveContactFrame(contact.publicKey));
|
||||
_contacts.removeWhere((c) => c.publicKeyHex == contact.publicKeyHex);
|
||||
_knownContactKeys.remove(contact.publicKeyHex);
|
||||
unawaited(updateKnownDiscovered());
|
||||
unawaited(_persistContacts());
|
||||
_conversations.remove(contact.publicKeyHex);
|
||||
_loadedConversationKeys.remove(contact.publicKeyHex);
|
||||
@@ -3450,12 +3569,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
|
||||
Future<void> sendCliCommand(String command) async {
|
||||
if (!isConnected) return;
|
||||
|
||||
// CLI commands are sent as UTF-8 text with a special prefix
|
||||
final commandBytes = utf8.encode(command);
|
||||
final bytes = Uint8List.fromList([0x01, ...commandBytes, 0x00]);
|
||||
final selfKey = _selfPublicKey;
|
||||
if (selfKey == null) return;
|
||||
_lastSentWasCliCommand = true;
|
||||
await sendFrame(bytes);
|
||||
await sendFrame(buildSendCliCommandFrame(selfKey, command));
|
||||
}
|
||||
|
||||
Future<void> setNodeName(String name) async {
|
||||
@@ -3929,8 +4046,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
_advertLocPolicy = reader.readByte();
|
||||
final telemetryFlag = reader.readByte();
|
||||
_telemetryModeBase = telemetryFlag & 0x03;
|
||||
_telemetryModeEnv = telemetryFlag >> 2 & 0x03;
|
||||
_telemetryModeLoc = telemetryFlag >> 4 & 0x03;
|
||||
_telemetryModeLoc = telemetryFlag >> 2 & 0x03;
|
||||
_telemetryModeEnv = telemetryFlag >> 4 & 0x03;
|
||||
|
||||
_manualAddContacts = reader.readByte() & 0x01 == 0x00;
|
||||
|
||||
@@ -4275,7 +4392,14 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
tag: 'Connector',
|
||||
);
|
||||
notifyListeners();
|
||||
removeContact(contactTmp);
|
||||
unawaited(
|
||||
removeContact(contactTmp).catchError(
|
||||
(e) => appLogger.warn(
|
||||
'Failed to remove self contact: $e',
|
||||
tag: 'Connector',
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final contact = getFromDiscovered(contactTmp);
|
||||
@@ -5521,6 +5645,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
}
|
||||
|
||||
messages.add(message);
|
||||
if (messages.length > _messageWindowSize) {
|
||||
messages.removeRange(0, messages.length - _messageWindowSize);
|
||||
}
|
||||
_messageStore.saveMessages(pubKeyHex, messages);
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -5907,20 +6034,24 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
bool _isChannelRepeat(ChannelMessage existing, ChannelMessage incoming) {
|
||||
if (existing.text != incoming.text) return false;
|
||||
|
||||
// Self-echo: an outgoing message coming back via a repeater. The send is
|
||||
// delayed by _waitForRadioQuiet (often 10s+) and propagation can add more,
|
||||
// so the timestamp gap can easily exceed the cross-peer window.
|
||||
final selfName = _selfName ?? 'Me';
|
||||
final isSelfEcho =
|
||||
existing.isOutgoing &&
|
||||
!incoming.isOutgoing &&
|
||||
(incoming.senderName == selfName || existing.senderName == selfName);
|
||||
|
||||
final windowMs = isSelfEcho ? 10 * 60 * 1000 : 30000;
|
||||
final diffMs =
|
||||
(existing.timestamp.millisecondsSinceEpoch -
|
||||
incoming.timestamp.millisecondsSinceEpoch)
|
||||
.abs();
|
||||
if (diffMs > 30000) return false;
|
||||
if (diffMs > windowMs) return false;
|
||||
|
||||
if (existing.senderName == incoming.senderName) return true;
|
||||
|
||||
if (existing.isOutgoing && !incoming.isOutgoing) {
|
||||
final selfName = _selfName ?? 'Me';
|
||||
if (incoming.senderName == selfName || existing.senderName == selfName) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (isSelfEcho) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -6152,6 +6283,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
@override
|
||||
void dispose() {
|
||||
_scanSubscription?.cancel();
|
||||
_isScanningSubscription?.cancel();
|
||||
_connectionSubscription?.cancel();
|
||||
_usbFrameSubscription?.cancel();
|
||||
_notifySubscription?.cancel();
|
||||
@@ -6210,82 +6342,6 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
void importContact(Uint8List frame) {
|
||||
final packet = BufferReader(frame);
|
||||
int payloadType = 0;
|
||||
Uint8List pathBytes = Uint8List(0);
|
||||
try {
|
||||
packet.skipBytes(1); // Skip frame type byte
|
||||
packet.skipBytes(1); // Skip SNR byte
|
||||
packet.skipBytes(1); // Skip RSSI byte
|
||||
final header = packet.readByte();
|
||||
final routeType = header & 0x03;
|
||||
payloadType = (header >> 2) & 0x0F;
|
||||
if (routeType == _routeTransportFlood ||
|
||||
routeType == _routeTransportDirect) {
|
||||
packet.skipBytes(4); // Skip transport-specific bytes
|
||||
}
|
||||
//final payloadVer = (header >> 6) & 0x03;
|
||||
final pathLenRaw = packet.readByte();
|
||||
final pathByteLen = _decodePathByteLen(pathLenRaw);
|
||||
pathBytes = packet.readBytes(pathByteLen);
|
||||
} catch (e) {
|
||||
appLogger.warn('Malformed RX frame: $e', tag: 'Connector');
|
||||
return;
|
||||
}
|
||||
double? latitude;
|
||||
double? longitude;
|
||||
String name = '';
|
||||
Uint8List publicKey = Uint8List(0);
|
||||
int type = 0;
|
||||
int timestamp = 0;
|
||||
bool hasLocation = false;
|
||||
bool hasName = false;
|
||||
if (payloadType != payloadTypeADVERT) {
|
||||
appLogger.warn('Unexpected payload type: $payloadType', tag: 'Connector');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
publicKey = packet.readBytes(32);
|
||||
timestamp = packet.readInt32LE();
|
||||
//TODO add signature verification
|
||||
packet.skipBytes(64); // Skip signature for now
|
||||
final flags = packet.readByte();
|
||||
type = flags & 0x0F;
|
||||
hasLocation = (flags & 0x10) != 0;
|
||||
// For future use:
|
||||
//final hasFeature1 = (flags & 0x20) != 0;
|
||||
//final hasFeature2 = (flags & 0x40) != 0;
|
||||
hasName = (flags & 0x80) != 0;
|
||||
if (hasLocation && packet.remaining >= 8) {
|
||||
latitude = packet.readInt32LE() / 1e6;
|
||||
longitude = packet.readInt32LE() / 1e6;
|
||||
}
|
||||
if (hasName && packet.remaining > 0) {
|
||||
name = packet.readCString();
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.warn('Malformed advert frame: $e', tag: 'Connector');
|
||||
return;
|
||||
}
|
||||
|
||||
importDiscoveredContact(
|
||||
Contact(
|
||||
rawPacket: frame,
|
||||
publicKey: publicKey,
|
||||
name: name,
|
||||
type: type,
|
||||
pathLength: pathBytes.isEmpty ? -1 : pathBytes.length,
|
||||
path: Uint8List.fromList(
|
||||
pathBytes.reversed.toList(),
|
||||
), // Store path in reverse for easier use in outgoing messages
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool hasValidLocation(double? latitude, double? longitude) {
|
||||
const double epsilon = 1e-6;
|
||||
final lat = latitude ?? 0.0;
|
||||
|
||||
@@ -457,8 +457,13 @@ String pubKeyToHex(Uint8List pubKey) {
|
||||
|
||||
// Helper to convert hex string to public key
|
||||
Uint8List hexToPubKey(String hex) {
|
||||
if (hex.length != pubKeySize * 2) {
|
||||
throw FormatException(
|
||||
'Public key hex must be ${pubKeySize * 2} chars, got ${hex.length}',
|
||||
);
|
||||
}
|
||||
final result = Uint8List(pubKeySize);
|
||||
for (int i = 0; i < pubKeySize && i * 2 + 1 < hex.length; i++) {
|
||||
for (int i = 0; i < pubKeySize; i++) {
|
||||
result[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16);
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -3,6 +3,10 @@ class MeshCoreUuids {
|
||||
static const String rxCharacteristic = "6e400002-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
static const String txCharacteristic = "6e400003-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
|
||||
/// Known advertised-name prefixes used by stock MeshCore firmware builds.
|
||||
/// Discovery no longer filters on these (it filters on the [service] UUID so
|
||||
/// that community forks with custom names are still found); kept for
|
||||
/// reference and possible future display heuristics.
|
||||
static const List<String> deviceNamePrefixes = [
|
||||
"MeshCore-",
|
||||
"Whisper-",
|
||||
|
||||
Reference in New Issue
Block a user