added buildSetCustomVarFrame and setCustomVar

This commit is contained in:
Winston Lowe 2026-01-18 01:02:48 -08:00
parent e0a8fb7ec0
commit 1f0b7d8d7b
2 changed files with 356 additions and 183 deletions

View file

@ -66,8 +66,10 @@ class MeshCoreConnector extends ChangeNotifier {
final Map<String, List<Message>> _conversations = {};
final Map<int, List<ChannelMessage>> _channelMessages = {};
final Set<String> _loadedConversationKeys = {};
final Map<int, Set<String>> _processedChannelReactions = {}; // channelIndex -> Set of "reactionKey_emoji"
final Map<String, Set<String>> _processedContactReactions = {}; // contactPubKeyHex -> Set of "reactionKey_emoji"
final Map<int, Set<String>> _processedChannelReactions =
{}; // channelIndex -> Set of "reactionKey_emoji"
final Map<String, Set<String>> _processedContactReactions =
{}; // contactPubKeyHex -> Set of "reactionKey_emoji"
StreamSubscription<List<ScanResult>>? _scanSubscription;
StreamSubscription<BluetoothConnectionState>? _connectionSubscription;
@ -137,7 +139,8 @@ class MeshCoreConnector extends ChangeNotifier {
final ContactStore _contactStore = ContactStore();
final UnreadStore _unreadStore = UnreadStore();
final Map<int, bool> _channelSmazEnabled = {};
bool _lastSentWasCliCommand = false; // Track if last sent message was a CLI command
bool _lastSentWasCliCommand =
false; // Track if last sent message was a CLI command
final Map<String, bool> _contactSmazEnabled = {};
final Set<String> _knownContactKeys = {};
final Map<String, int> _contactLastReadMs = {};
@ -166,6 +169,7 @@ class MeshCoreConnector extends ChangeNotifier {
}
return 'Unknown Device';
}
List<ScanResult> get scanResults => List.unmodifiable(_scanResults);
List<Contact> get contacts {
final selfKey = _selfPublicKey;
@ -176,6 +180,7 @@ class MeshCoreConnector extends ChangeNotifier {
_contacts.where((contact) => !listEquals(contact.publicKey, selfKey)),
);
}
List<Channel> get channels => List.unmodifiable(_channels);
bool get isConnected => _state == MeshCoreConnectionState.connected;
bool get isLoadingContacts => _isLoadingContacts;
@ -196,7 +201,8 @@ class MeshCoreConnector extends ChangeNotifier {
int get maxChannels => _maxChannels;
bool get isSyncingQueuedMessages => _isSyncingQueuedMessages;
bool get isSyncingChannels => _isSyncingChannels;
int get channelSyncProgress => _isSyncingChannels && _totalChannelsToRequest > 0
int get channelSyncProgress =>
_isSyncingChannels && _totalChannelsToRequest > 0
? ((_nextChannelIndexToRequest / _totalChannelsToRequest) * 100).round()
: 0;
int? get batteryPercent => _batteryMillivolts == null
@ -377,7 +383,8 @@ class MeshCoreConnector extends ChangeNotifier {
}
void setActiveContact(String? contactKeyHex) {
if (contactKeyHex != null && !_shouldTrackUnreadForContactKey(contactKeyHex)) {
if (contactKeyHex != null &&
!_shouldTrackUnreadForContactKey(contactKeyHex)) {
_activeContactKey = null;
return;
}
@ -429,7 +436,9 @@ class MeshCoreConnector extends ChangeNotifier {
/// Load persisted channel messages for a specific channel
Future<void> _loadChannelMessages(int channelIndex) async {
final allMessages = await _channelMessageStore.loadChannelMessages(channelIndex);
final allMessages = await _channelMessageStore.loadChannelMessages(
channelIndex,
);
if (allMessages.isNotEmpty) {
// Keep only the most recent N messages in memory to bound memory usage
final windowedMessages = allMessages.length > _messageWindowSize
@ -446,7 +455,9 @@ class MeshCoreConnector extends ChangeNotifier {
int channelIndex, {
int count = 50,
}) async {
final allMessages = await _channelMessageStore.loadChannelMessages(channelIndex);
final allMessages = await _channelMessageStore.loadChannelMessages(
channelIndex,
);
final currentMessages = _channelMessages[channelIndex] ?? [];
if (allMessages.length <= currentMessages.length) {
@ -551,7 +562,9 @@ class MeshCoreConnector extends ChangeNotifier {
final contactKey = pubKeyToHex(message.senderKey);
final messages = _conversations[contactKey];
if (messages != null) {
final index = messages.indexWhere((m) => m.messageId == message.messageId);
final index = messages.indexWhere(
(m) => m.messageId == message.messageId,
);
if (index != -1) {
messages[index] = message;
_messageStore.saveMessages(contactKey, messages);
@ -576,7 +589,9 @@ class MeshCoreConnector extends ChangeNotifier {
}
Contact _applyAutoSelection(Contact contact, PathSelection? selection) {
if (selection == null || selection.useFlood || selection.pathBytes.isEmpty) {
if (selection == null ||
selection.useFlood ||
selection.pathBytes.isEmpty) {
return contact;
}
@ -584,7 +599,9 @@ class MeshCoreConnector extends ChangeNotifier {
publicKey: contact.publicKey,
name: contact.name,
type: contact.type,
pathLength: selection.hopCount >= 0 ? selection.hopCount : contact.pathLength,
pathLength: selection.hopCount >= 0
? selection.hopCount
: contact.pathLength,
path: Uint8List.fromList(selection.pathBytes),
latitude: contact.latitude,
longitude: contact.longitude,
@ -593,7 +610,9 @@ class MeshCoreConnector extends ChangeNotifier {
);
}
Future<void> startScan({Duration timeout = const Duration(seconds: 10)}) async {
Future<void> startScan({
Duration timeout = const Duration(seconds: 10),
}) async {
if (_state == MeshCoreConnectionState.scanning) return;
_scanResults.clear();
@ -714,7 +733,9 @@ class MeshCoreConnector extends ChangeNotifier {
if (attempt == 2) rethrow;
}
}
_notifySubscription = _txCharacteristic!.onValueReceived.listen(_handleFrame);
_notifySubscription = _txCharacteristic!.onValueReceived.listen(
_handleFrame,
);
_setState(MeshCoreConnectionState.connected);
@ -771,8 +792,7 @@ class MeshCoreConnector extends ChangeNotifier {
return result;
}
bool get _shouldAutoReconnect =>
!_manualDisconnect && _lastDeviceId != null;
bool get _shouldAutoReconnect => !_manualDisconnect && _lastDeviceId != null;
void _cancelReconnectTimer() {
_reconnectTimer?.cancel();
@ -799,7 +819,8 @@ class MeshCoreConnector extends ChangeNotifier {
return;
}
final device = _lastDevice ??
final device =
_lastDevice ??
(_lastDeviceId == null
? null
: BluetoothDevice.fromId(_lastDeviceId!));
@ -945,20 +966,19 @@ class MeshCoreConnector extends ChangeNotifier {
void _scheduleSelfInfoRetry() {
_selfInfoRetryTimer?.cancel();
_selfInfoRetryTimer = Timer.periodic(
const Duration(milliseconds: 3500),
(timer) {
if (!isConnected) {
timer.cancel();
return;
}
if (!_awaitingSelfInfo) {
timer.cancel();
return;
}
unawaited(sendFrame(buildAppStartFrame()));
},
);
_selfInfoRetryTimer = Timer.periodic(const Duration(milliseconds: 3500), (
timer,
) {
if (!isConnected) {
timer.cancel();
return;
}
if (!_awaitingSelfInfo) {
timer.cancel();
return;
}
unawaited(sendFrame(buildAppStartFrame()));
});
}
Future<void> getContacts({int? since, bool preserveExisting = false}) async {
@ -979,10 +999,7 @@ class MeshCoreConnector extends ChangeNotifier {
}
Future<void> refreshContactsSinceLastmod() async {
await getContacts(
since: _latestContactLastmod(),
preserveExisting: true,
);
await getContacts(since: _latestContactLastmod(), preserveExisting: true);
}
Future<void> getContactByKey(Uint8List pubKey) async {
@ -990,18 +1007,20 @@ class MeshCoreConnector extends ChangeNotifier {
await sendFrame(buildGetContactByKeyFrame(pubKey));
}
Future<void> sendMessage(
Contact contact,
String text,
) async {
Future<void> sendMessage(Contact contact, String text) async {
if (!isConnected || text.isEmpty) return;
// Handle auto-rotation if enabled
PathSelection? autoSelection;
if (_appSettingsService?.settings.autoRouteRotationEnabled == true) {
autoSelection = _pathHistoryService?.getNextAutoPathSelection(contact.publicKeyHex);
autoSelection = _pathHistoryService?.getNextAutoPathSelection(
contact.publicKeyHex,
);
if (autoSelection != null) {
_pathHistoryService?.recordPathAttempt(contact.publicKeyHex, autoSelection);
_pathHistoryService?.recordPathAttempt(
contact.publicKeyHex,
autoSelection,
);
if (!autoSelection.useFlood && autoSelection.pathBytes.isNotEmpty) {
await setContactPath(
contact,
@ -1036,12 +1055,7 @@ class MeshCoreConnector extends ChangeNotifier {
_addMessage(contact.publicKeyHex, message);
notifyListeners();
final outboundText = prepareContactOutboundText(contact, text);
await sendFrame(
buildSendTextMsgFrame(
contact.publicKey,
outboundText,
),
);
await sendFrame(buildSendTextMsgFrame(contact.publicKey, outboundText));
}
}
@ -1052,13 +1066,15 @@ class MeshCoreConnector extends ChangeNotifier {
) async {
if (!isConnected) return;
await sendFrame(buildUpdateContactPathFrame(
contact.publicKey,
customPath,
pathLen,
type: contact.type,
name: contact.name,
));
await sendFrame(
buildUpdateContactPathFrame(
contact.publicKey,
customPath,
pathLen,
type: contact.type,
name: contact.name,
),
);
}
/// Set path override for a contact (persists across contact refreshes)
@ -1068,16 +1084,27 @@ class MeshCoreConnector extends ChangeNotifier {
int? pathLen,
Uint8List? pathBytes,
}) async {
appLogger.info('setPathOverride called for ${contact.name}: pathLen=$pathLen, bytesLen=${pathBytes?.length ?? 0}', tag: 'Connector');
appLogger.info(
'setPathOverride called for ${contact.name}: pathLen=$pathLen, bytesLen=${pathBytes?.length ?? 0}',
tag: 'Connector',
);
// Find contact in list
final index = _contacts.indexWhere((c) => c.publicKeyHex == contact.publicKeyHex);
final index = _contacts.indexWhere(
(c) => c.publicKeyHex == contact.publicKeyHex,
);
if (index == -1) {
appLogger.warn('setPathOverride: Contact not found in list: ${contact.name}', tag: 'Connector');
appLogger.warn(
'setPathOverride: Contact not found in list: ${contact.name}',
tag: 'Connector',
);
return;
}
appLogger.info('Found contact at index $index. Current override: ${_contacts[index].pathOverride}', tag: 'Connector');
appLogger.info(
'Found contact at index $index. Current override: ${_contacts[index].pathOverride}',
tag: 'Connector',
);
// Update contact with new path override
_contacts[index] = _contacts[index].copyWith(
@ -1086,7 +1113,10 @@ class MeshCoreConnector extends ChangeNotifier {
clearPathOverride: pathLen == null, // Clear if pathLen is null
);
appLogger.info('Updated contact. New override: ${_contacts[index].pathOverride}, bytesLen: ${_contacts[index].pathOverrideBytes?.length}', tag: 'Connector');
appLogger.info(
'Updated contact. New override: ${_contacts[index].pathOverride}, bytesLen: ${_contacts[index].pathOverrideBytes?.length}',
tag: 'Connector',
);
// Save to storage
await _contactStore.saveContacts(_contacts);
@ -1099,7 +1129,9 @@ class MeshCoreConnector extends ChangeNotifier {
appLogger.info('Path sent to device', tag: 'Connector');
}
debugPrint('Set path override for ${contact.name}: pathLen=$pathLen, bytes=${pathBytes?.length ?? 0}');
debugPrint(
'Set path override for ${contact.name}: pathLen=$pathLen, bytes=${pathBytes?.length ?? 0}',
);
notifyListeners();
}
@ -1148,7 +1180,9 @@ class MeshCoreConnector extends ChangeNotifier {
outboundText,
selfKey,
);
final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
final ackHashHex = ackHash
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final messageBytes = utf8.encode(outboundText).length;
_pendingRepeaterAcks[ackHashHex]?.timeout?.cancel();
_pendingRepeaterAcks[ackHashHex] = _RepeaterAckContext(
@ -1206,7 +1240,7 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
Future<void> sendChannelMessage(Channel channel, String text) async{
Future<void> sendChannelMessage(Channel channel, String text) async {
if (!isConnected || text.isEmpty) return;
// Check if this is a reaction - if so, process it immediately instead of adding as a message
@ -1215,9 +1249,14 @@ class MeshCoreConnector extends ChangeNotifier {
// Check if we've already processed this reaction
_processedChannelReactions.putIfAbsent(channel.index, () => {});
final reactionKey = reactionInfo.reactionKey;
final reactionIdentifier = reactionKey != null ? '${reactionKey}_${reactionInfo.emoji}' : null;
final reactionIdentifier = reactionKey != null
? '${reactionKey}_${reactionInfo.emoji}'
: null;
if (reactionIdentifier != null && _processedChannelReactions[channel.index]!.contains(reactionIdentifier)) {
if (reactionIdentifier != null &&
_processedChannelReactions[channel.index]!.contains(
reactionIdentifier,
)) {
// Already processed, don't process again
return;
}
@ -1242,13 +1281,19 @@ class MeshCoreConnector extends ChangeNotifier {
return;
}
final message = ChannelMessage.outgoing(text, _selfName ?? 'Me', channel.index);
final message = ChannelMessage.outgoing(
text,
_selfName ?? 'Me',
channel.index,
);
_addChannelMessage(channel.index, message);
notifyListeners();
final trimmed = text.trim();
final isStructuredPayload = trimmed.startsWith('g:') || trimmed.startsWith('m:');
final outboundText = (isChannelSmazEnabled(channel.index) && !isStructuredPayload)
final isStructuredPayload =
trimmed.startsWith('g:') || trimmed.startsWith('m:');
final outboundText =
(isChannelSmazEnabled(channel.index) && !isStructuredPayload)
? Smaz.encodeIfSmaller(text)
: text;
await sendFrame(buildSendChannelTextMsgFrame(channel.index, outboundText));
@ -1264,9 +1309,7 @@ class MeshCoreConnector extends ChangeNotifier {
_conversations.remove(contact.publicKeyHex);
_loadedConversationKeys.remove(contact.publicKeyHex);
_contactLastReadMs.remove(contact.publicKeyHex);
_unreadStore.saveContactLastRead(
Map<String, int>.from(_contactLastReadMs),
);
_unreadStore.saveContactLastRead(Map<String, int>.from(_contactLastReadMs));
_messageStore.clearMessages(contact.publicKeyHex);
notifyListeners();
}
@ -1275,8 +1318,9 @@ class MeshCoreConnector extends ChangeNotifier {
if (!isConnected) return;
await sendFrame(buildResetPathFrame(contact.publicKey));
final existingIndex =
_contacts.indexWhere((c) => c.publicKeyHex == contact.publicKeyHex);
final existingIndex = _contacts.indexWhere(
(c) => c.publicKeyHex == contact.publicKeyHex,
);
if (existingIndex >= 0) {
final existing = _contacts[existingIndex];
// Use copyWith to preserve pathOverride and pathOverrideBytes
@ -1295,8 +1339,9 @@ class MeshCoreConnector extends ChangeNotifier {
Uint8List? pathBytes,
int? pathLength,
}) {
final existingIndex =
_contacts.indexWhere((c) => c.publicKeyHex == publicKeyHex);
final existingIndex = _contacts.indexWhere(
(c) => c.publicKeyHex == publicKeyHex,
);
if (existingIndex >= 0) {
final existing = _contacts[existingIndex];
_contacts[existingIndex] = existing.copyWith(
@ -1344,7 +1389,9 @@ class MeshCoreConnector extends ChangeNotifier {
_handleQueueSyncTimeout();
});
debugPrint('[QueueSync] Requesting next message (retry: $_queueSyncRetries/$_maxQueueSyncRetries)');
debugPrint(
'[QueueSync] Requesting next message (retry: $_queueSyncRetries/$_maxQueueSyncRetries)',
);
try {
await sendFrame(buildSyncNextMessageFrame());
@ -1358,7 +1405,9 @@ class MeshCoreConnector extends ChangeNotifier {
}
void _handleQueueSyncTimeout() {
debugPrint('[QueueSync] Timeout waiting for message (retry: $_queueSyncRetries/$_maxQueueSyncRetries)');
debugPrint(
'[QueueSync] Timeout waiting for message (retry: $_queueSyncRetries/$_maxQueueSyncRetries)',
);
if (_queueSyncRetries < _maxQueueSyncRetries) {
// Retry
@ -1389,11 +1438,19 @@ class MeshCoreConnector extends ChangeNotifier {
await sendFrame(buildSetAdvertNameFrame(name));
}
Future<void> setNodeLocation({required double lat, required double lon}) async {
Future<void> setNodeLocation({
required double lat,
required double lon,
}) async {
if (!isConnected) return;
await sendFrame(buildSetAdvertLatLonFrame(lat, lon));
}
Future<void> setCustomVar(String value) async {
if (!isConnected) return;
await sendFrame(buildSetCustomVarFrame(value));
}
Future<void> sendSelfAdvert({bool flood = true}) async {
if (!isConnected) return;
await sendFrame(buildSendSelfAdvertFrame(flood: flood));
@ -1424,7 +1481,9 @@ class MeshCoreConnector extends ChangeNotifier {
_channelSyncRetries = 0;
notifyListeners();
debugPrint('[ChannelSync] Starting sync for $_totalChannelsToRequest channels');
debugPrint(
'[ChannelSync] Starting sync for $_totalChannelsToRequest channels',
);
// Start sequential sync
await _requestNextChannel();
@ -1456,7 +1515,9 @@ class MeshCoreConnector extends ChangeNotifier {
() => _handleChannelSyncTimeout(channelIndex),
);
debugPrint('[ChannelSync] Requesting channel $channelIndex/$_totalChannelsToRequest (retry: $_channelSyncRetries/$_maxChannelSyncRetries)');
debugPrint(
'[ChannelSync] Requesting channel $channelIndex/$_totalChannelsToRequest (retry: $_channelSyncRetries/$_maxChannelSyncRetries)',
);
try {
await sendFrame(buildGetChannelFrame(channelIndex));
@ -1468,7 +1529,9 @@ class MeshCoreConnector extends ChangeNotifier {
}
void _handleChannelSyncTimeout(int channelIndex) {
debugPrint('[ChannelSync] Timeout waiting for channel $channelIndex (retry: $_channelSyncRetries/$_maxChannelSyncRetries)');
debugPrint(
'[ChannelSync] Timeout waiting for channel $channelIndex (retry: $_channelSyncRetries/$_maxChannelSyncRetries)',
);
if (_channelSyncRetries < _maxChannelSyncRetries) {
// Retry the same channel
@ -1477,16 +1540,20 @@ class MeshCoreConnector extends ChangeNotifier {
unawaited(_requestNextChannel());
} else {
// Max retries reached for this channel, restore from cache and move to next
debugPrint('[ChannelSync] Max retries reached for channel $channelIndex, attempting cache restore');
debugPrint(
'[ChannelSync] Max retries reached for channel $channelIndex, attempting cache restore',
);
// Try to restore this channel from cache
try {
final cachedChannel = _previousChannelsCache.firstWhere(
(c) => c.index == channelIndex
(c) => c.index == channelIndex,
);
if (!cachedChannel.isEmpty) {
_channels.add(cachedChannel);
debugPrint('[ChannelSync] Restored channel $channelIndex (${cachedChannel.name}) from cache');
debugPrint(
'[ChannelSync] Restored channel $channelIndex (${cachedChannel.name}) from cache',
);
}
} catch (e) {
// No cached channel found, that's okay
@ -1503,7 +1570,9 @@ class MeshCoreConnector extends ChangeNotifier {
void _completeChannelSync() {
_channelSyncTimeout?.cancel();
debugPrint('[ChannelSync] Sync complete: received ${_channels.length}/$_totalChannelsToRequest channels');
debugPrint(
'[ChannelSync] Sync complete: received ${_channels.length}/$_totalChannelsToRequest channels',
);
_cleanupChannelSync(completed: true);
@ -1541,9 +1610,7 @@ class MeshCoreConnector extends ChangeNotifier {
// Delete by setting empty name and zero PSK
await sendFrame(buildSetChannelFrame(index, '', Uint8List(16)));
_channelLastReadMs.remove(index);
_unreadStore.saveChannelLastRead(
Map<int, int>.from(_channelLastReadMs),
);
_unreadStore.saveChannelLastRead(Map<int, int>.from(_channelLastReadMs));
// Refresh channels after deleting
await getChannels();
}
@ -1705,8 +1772,12 @@ class MeshCoreConnector extends ChangeNotifier {
// Firmware reports MAX_CONTACTS / 2 for v3+ device info.
final reportedContacts = frame[2];
final reportedChannels = frame[3];
final nextMaxContacts = reportedContacts > 0 ? reportedContacts * 2 : _maxContacts;
final nextMaxChannels = reportedChannels > 0 ? reportedChannels : _maxChannels;
final nextMaxContacts = reportedContacts > 0
? reportedContacts * 2
: _maxContacts;
final nextMaxChannels = reportedChannels > 0
? reportedChannels
: _maxChannels;
final previousMaxChannels = _maxChannels;
if (nextMaxContacts != _maxContacts || nextMaxChannels != _maxChannels) {
_maxContacts = nextMaxContacts;
@ -1751,7 +1822,9 @@ class MeshCoreConnector extends ChangeNotifier {
_currentBwHz = readUint32LE(frame, 5);
_currentSf = frame[9];
_currentCr = frame[10];
debugPrint('Radio settings: freq=$_currentFreqHz bw=$_currentBwHz sf=$_currentSf cr=$_currentCr');
debugPrint(
'Radio settings: freq=$_currentFreqHz bw=$_currentBwHz sf=$_currentSf cr=$_currentCr',
);
notifyListeners();
}
}
@ -1822,11 +1895,15 @@ class MeshCoreConnector extends ChangeNotifier {
if (existingIndex >= 0) {
final existing = _contacts[existingIndex];
final mergedLastMessageAt = existing.lastMessageAt.isAfter(contact.lastMessageAt)
final mergedLastMessageAt =
existing.lastMessageAt.isAfter(contact.lastMessageAt)
? existing.lastMessageAt
: contact.lastMessageAt;
appLogger.info('Refreshing contact ${contact.name}: devicePath=${contact.pathLength}, existingOverride=${existing.pathOverride}', tag: 'Connector');
appLogger.info(
'Refreshing contact ${contact.name}: devicePath=${contact.pathLength}, existingOverride=${existing.pathOverride}',
tag: 'Connector',
);
// CRITICAL: Preserve user's path override when contact is refreshed from device
_contacts[existingIndex] = contact.copyWith(
@ -1835,10 +1912,16 @@ class MeshCoreConnector extends ChangeNotifier {
pathOverrideBytes: existing.pathOverrideBytes,
);
appLogger.info('After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}', tag: 'Connector');
appLogger.info(
'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}',
tag: 'Connector',
);
} else {
_contacts.add(contact);
appLogger.info('Added new contact ${contact.name}: pathLen=${contact.pathLength}', tag: 'Connector');
appLogger.info(
'Added new contact ${contact.name}: pathLen=${contact.pathLength}',
tag: 'Connector',
);
}
_knownContactKeys.add(contact.publicKeyHex);
_loadMessagesForContact(contact.publicKeyHex);
@ -1970,9 +2053,13 @@ class MeshCoreConnector extends ChangeNotifier {
if (message == null && !_isLoadingContacts) {
final senderPrefix = _extractSenderPrefix(frame);
if (senderPrefix != null) {
final hasContact = _contacts.any((c) => _matchesPrefix(c.publicKey, senderPrefix));
final hasContact = _contacts.any(
(c) => _matchesPrefix(c.publicKey, senderPrefix),
);
if (!hasContact) {
debugPrint('Received message from unknown contact, refreshing contacts...');
debugPrint(
'Received message from unknown contact, refreshing contacts...',
);
await refreshContactsSinceLastmod();
// Retry parsing after refresh
message = _parseContactMessage(frame);
@ -2017,7 +2104,9 @@ class MeshCoreConnector extends ChangeNotifier {
notifyListeners();
// Show notification for new incoming message
if (!message.isOutgoing && !message.isCli && _appSettingsService != null) {
if (!message.isOutgoing &&
!message.isCli &&
_appSettingsService != null) {
final settings = _appSettingsService!.settings;
if (settings.notificationsEnabled && settings.notifyOnNewMessage) {
// Find the contact name
@ -2074,9 +2163,17 @@ class MeshCoreConnector extends ChangeNotifier {
// Try base text offset; if empty and there is room for the optional 4-byte extra
// (used by signed/plain variants), try again skipping those bytes.
var text = readCString(frame, baseTextOffset, frame.length - baseTextOffset);
var text = readCString(
frame,
baseTextOffset,
frame.length - baseTextOffset,
);
if (text.isEmpty && frame.length > baseTextOffset + 4) {
text = readCString(frame, baseTextOffset + 4, frame.length - (baseTextOffset + 4));
text = readCString(
frame,
baseTextOffset + 4,
frame.length - (baseTextOffset + 4),
);
}
if (text.isEmpty) return null;
final decodedText = isCli ? text : (Smaz.tryDecodePrefixed(text) ?? text);
@ -2099,7 +2196,7 @@ class MeshCoreConnector extends ChangeNotifier {
status: MessageStatus.delivered,
pathLength: pathLenByte == 0xFF ? 0 : pathLenByte,
pathBytes: Uint8List(0),
fourByteRoomContactKey: fourBytePubMSG
fourByteRoomContactKey: fourBytePubMSG,
);
}
@ -2140,17 +2237,15 @@ class MeshCoreConnector extends ChangeNotifier {
String prepareContactOutboundText(Contact contact, String text) {
final trimmed = text.trim();
final isStructuredPayload =
trimmed.startsWith('g:') || trimmed.startsWith('m:') || trimmed.startsWith('V1|');
trimmed.startsWith('g:') ||
trimmed.startsWith('m:') ||
trimmed.startsWith('V1|');
if (!isStructuredPayload && isContactSmazEnabled(contact.publicKeyHex)) {
return Smaz.encodeIfSmaller(text);
}
return text;
}
String _channelDisplayName(int channelIndex) {
for (final channel in _channels) {
if (channel.index != channelIndex) continue;
@ -2184,7 +2279,10 @@ class MeshCoreConnector extends ChangeNotifier {
void _handleIncomingChannelMessage(Uint8List frame) {
final message = ChannelMessage.fromFrame(frame);
if (message != null && message.channelIndex != null) {
if (_shouldDropSelfChannelMessage(message.senderName, message.pathBytes)) {
if (_shouldDropSelfChannelMessage(
message.senderName,
message.pathBytes,
)) {
return;
}
_updateContactLastMessageAtByName(
@ -2257,7 +2355,9 @@ class MeshCoreConnector extends ChangeNotifier {
_maybeMarkActiveChannelRead(message);
notifyListeners();
if (isNew) {
final label = channel.name.isEmpty ? 'Channel ${channel.index}' : channel.name;
final label = channel.name.isEmpty
? 'Channel ${channel.index}'
: channel.name;
_maybeNotifyChannelMessage(message, channelName: label);
}
return;
@ -2277,7 +2377,9 @@ class MeshCoreConnector extends ChangeNotifier {
// Check if this is a CLI command ACK - if so, ignore it
if (_lastSentWasCliCommand) {
final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
final ackHashHex = ackHash
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
debugPrint('Ignoring CLI command ACK (sent): $ackHashHex');
_lastSentWasCliCommand = false;
return;
@ -2294,7 +2396,8 @@ class MeshCoreConnector extends ChangeNotifier {
// Fallback to old behavior
for (var messages in _conversations.values) {
for (int i = messages.length - 1; i >= 0; i--) {
if (messages[i].isOutgoing && messages[i].status == MessageStatus.pending) {
if (messages[i].isOutgoing &&
messages[i].status == MessageStatus.pending) {
messages[i] = messages[i].copyWith(status: MessageStatus.sent);
notifyListeners();
return;
@ -2328,7 +2431,8 @@ class MeshCoreConnector extends ChangeNotifier {
// Fallback to old behavior
for (var messages in _conversations.values) {
for (int i = messages.length - 1; i >= 0; i--) {
if (messages[i].isOutgoing && messages[i].status == MessageStatus.sent) {
if (messages[i].isOutgoing &&
messages[i].status == MessageStatus.sent) {
messages[i] = messages[i].copyWith(status: MessageStatus.delivered);
notifyListeners();
return;
@ -2339,7 +2443,9 @@ class MeshCoreConnector extends ChangeNotifier {
}
bool _handleRepeaterCommandSent(Uint8List ackHash, int timeoutMs) {
final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
final ackHashHex = ackHash
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final entry = _pendingRepeaterAcks[ackHashHex];
if (entry == null) return false;
@ -2358,7 +2464,9 @@ class MeshCoreConnector extends ChangeNotifier {
}
bool _handleRepeaterCommandAck(Uint8List ackHash, int tripTimeMs) {
final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
final ackHashHex = ackHash
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final entry = _pendingRepeaterAcks.remove(ackHashHex);
if (entry == null) return false;
entry.timeout?.cancel();
@ -2370,7 +2478,9 @@ class MeshCoreConnector extends ChangeNotifier {
final channel = Channel.fromFrame(frame);
if (channel == null) return;
debugPrint('[ChannelSync] Received channel ${channel.index}: ${channel.isEmpty ? "empty" : channel.name}');
debugPrint(
'[ChannelSync] Received channel ${channel.index}: ${channel.isEmpty ? "empty" : channel.name}',
);
// If we're syncing and this is the channel we're waiting for
if (_isSyncingChannels && _channelSyncInFlight) {
@ -2392,9 +2502,12 @@ class MeshCoreConnector extends ChangeNotifier {
} else {
// Received a channel but not the one we're waiting for
// This can happen if device sends unsolicited updates
debugPrint('[ChannelSync] Received unexpected channel ${channel.index}, expected $_nextChannelIndexToRequest');
debugPrint(
'[ChannelSync] Received unexpected channel ${channel.index}, expected $_nextChannelIndexToRequest',
);
// Add it anyway but don't advance sync
if (!channel.isEmpty && !_channels.any((c) => c.index == channel.index)) {
if (!channel.isEmpty &&
!_channels.any((c) => c.index == channel.index)) {
_channels.add(channel);
}
return;
@ -2404,7 +2517,9 @@ class MeshCoreConnector extends ChangeNotifier {
// Not syncing, or received unsolicited update - handle normally
if (!channel.isEmpty) {
// Update or add channel
final existingIndex = _channels.indexWhere((c) => c.index == channel.index);
final existingIndex = _channels.indexWhere(
(c) => c.index == channel.index,
);
if (existingIndex >= 0) {
_channels[existingIndex] = channel;
} else {
@ -2469,26 +2584,30 @@ class MeshCoreConnector extends ChangeNotifier {
return latestMs;
}
void _setContactLastReadMs(String contactKeyHex, int timestampMs, {bool notify = true}) {
void _setContactLastReadMs(
String contactKeyHex,
int timestampMs, {
bool notify = true,
}) {
if (!_shouldTrackUnreadForContactKey(contactKeyHex)) return;
final existing = _contactLastReadMs[contactKeyHex] ?? 0;
if (timestampMs <= existing) return;
_contactLastReadMs[contactKeyHex] = timestampMs;
_unreadStore.saveContactLastRead(
Map<String, int>.from(_contactLastReadMs),
);
_unreadStore.saveContactLastRead(Map<String, int>.from(_contactLastReadMs));
if (notify) {
notifyListeners();
}
}
void _setChannelLastReadMs(int channelIndex, int timestampMs, {bool notify = true}) {
void _setChannelLastReadMs(
int channelIndex,
int timestampMs, {
bool notify = true,
}) {
final existing = _channelLastReadMs[channelIndex] ?? 0;
if (timestampMs <= existing) return;
_channelLastReadMs[channelIndex] = timestampMs;
_unreadStore.saveChannelLastRead(
Map<int, int>.from(_channelLastReadMs),
);
_unreadStore.saveChannelLastRead(Map<int, int>.from(_channelLastReadMs));
if (notify) {
notifyListeners();
}
@ -2526,9 +2645,12 @@ class MeshCoreConnector extends ChangeNotifier {
// Check if we've already processed this exact reaction using lightweight key
_processedContactReactions.putIfAbsent(pubKeyHex, () => {});
final reactionKey = reactionInfo.reactionKey;
final reactionIdentifier = reactionKey != null ? '${reactionKey}_${reactionInfo.emoji}' : null;
final reactionIdentifier = reactionKey != null
? '${reactionKey}_${reactionInfo.emoji}'
: null;
final isDuplicate = reactionIdentifier != null &&
final isDuplicate =
reactionIdentifier != null &&
_processedContactReactions[pubKeyHex]!.contains(reactionIdentifier);
if (!isDuplicate) {
@ -2551,7 +2673,10 @@ class MeshCoreConnector extends ChangeNotifier {
notifyListeners();
}
void _processContactReaction(List<Message> messages, ReactionInfo reactionInfo) {
void _processContactReaction(
List<Message> messages,
ReactionInfo reactionInfo,
) {
// Find target message by messageId
for (int i = 0; i < messages.length; i++) {
if (messages[i].messageId == reactionInfo.targetMessageId) {
@ -2570,7 +2695,8 @@ class MeshCoreConnector extends ChangeNotifier {
var index = 0;
final header = raw[index++];
final routeType = header & _phRouteMask;
final hasTransport = routeType == _routeTransportFlood || routeType == _routeTransportDirect;
final hasTransport =
routeType == _routeTransportFlood || routeType == _routeTransportDirect;
if (hasTransport) {
if (raw.length < index + 4) return null;
index += 4;
@ -2633,7 +2759,8 @@ class MeshCoreConnector extends ChangeNotifier {
if (RegExp(r'[:\[\]]').hasMatch(potentialSender)) {
return _ParsedText(senderName: 'Unknown', text: text);
}
final offset = (colonIndex + 1 < text.length && text[colonIndex + 1] == ' ')
final offset =
(colonIndex + 1 < text.length && text[colonIndex + 1] == ' ')
? colonIndex + 2
: colonIndex + 1;
return _ParsedText(
@ -2670,10 +2797,7 @@ class MeshCoreConnector extends ChangeNotifier {
return contact.path;
}
int? _resolveOutgoingPathLength(
Contact contact,
PathSelection? selection,
) {
int? _resolveOutgoingPathLength(Contact contact, PathSelection? selection) {
// Priority 1: Check user's path override
if (contact.pathOverride != null) {
return contact.pathOverride;
@ -2714,10 +2838,15 @@ class MeshCoreConnector extends ChangeNotifier {
// Check if we've already processed this exact reaction using lightweight key
_processedChannelReactions.putIfAbsent(channelIndex, () => {});
final reactionKey = reactionInfo.reactionKey;
final reactionIdentifier = reactionKey != null ? '${reactionKey}_${reactionInfo.emoji}' : null;
final reactionIdentifier = reactionKey != null
? '${reactionKey}_${reactionInfo.emoji}'
: null;
final isDuplicate = reactionIdentifier != null &&
_processedChannelReactions[channelIndex]!.contains(reactionIdentifier);
final isDuplicate =
reactionIdentifier != null &&
_processedChannelReactions[channelIndex]!.contains(
reactionIdentifier,
);
if (!isDuplicate) {
// New reaction - process it
@ -2739,7 +2868,10 @@ class MeshCoreConnector extends ChangeNotifier {
if (replyInfo != null) {
// Find original message by sender name (most recent match)
final originalMessage = _findMessageBySender(messages, replyInfo.mentionedNode);
final originalMessage = _findMessageBySender(
messages,
replyInfo.mentionedNode,
);
if (originalMessage != null) {
// Create new message with reply metadata
@ -2769,8 +2901,14 @@ class MeshCoreConnector extends ChangeNotifier {
if (existingIndex >= 0) {
isNew = false;
final existing = messages[existingIndex];
final mergedPathBytes = _selectPreferredPathBytes(existing.pathBytes, processedMessage.pathBytes);
final mergedPathVariants = _mergePathVariants(existing.pathVariants, processedMessage.pathVariants);
final mergedPathBytes = _selectPreferredPathBytes(
existing.pathBytes,
processedMessage.pathBytes,
);
final mergedPathVariants = _mergePathVariants(
existing.pathVariants,
processedMessage.pathVariants,
);
final mergedPathLength = _mergePathLength(
existing.pathLength,
processedMessage.pathLength,
@ -2783,7 +2921,9 @@ class MeshCoreConnector extends ChangeNotifier {
pathBytes: mergedPathBytes,
pathVariants: mergedPathVariants,
// Mark as sent when first repeat is heard
status: newRepeatCount == 1 && existing.status == ChannelMessageStatus.pending
status:
newRepeatCount == 1 &&
existing.status == ChannelMessageStatus.pending
? ChannelMessageStatus.sent
: existing.status,
);
@ -2792,14 +2932,14 @@ class MeshCoreConnector extends ChangeNotifier {
}
// Save to persistent storage
_channelMessageStore.saveChannelMessages(
channelIndex,
messages,
);
_channelMessageStore.saveChannelMessages(channelIndex, messages);
return isNew;
}
ChannelMessage? _findMessageBySender(List<ChannelMessage> messages, String mentionedNode) {
ChannelMessage? _findMessageBySender(
List<ChannelMessage> messages,
String mentionedNode,
) {
// Search backwards for most recent message from this sender
for (int i = messages.length - 1; i >= 0; i--) {
if (messages[i].senderName == mentionedNode && !messages[i].isOutgoing) {
@ -2809,7 +2949,10 @@ class MeshCoreConnector extends ChangeNotifier {
return null;
}
void _processReaction(List<ChannelMessage> messages, ReactionInfo reactionInfo) {
void _processReaction(
List<ChannelMessage> messages,
ReactionInfo reactionInfo,
) {
// Find target message by messageId
for (int i = 0; i < messages.length; i++) {
if (messages[i].messageId == reactionInfo.targetMessageId) {
@ -2824,7 +2967,10 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
int _findChannelRepeatIndex(List<ChannelMessage> messages, ChannelMessage incoming) {
int _findChannelRepeatIndex(
List<ChannelMessage> messages,
ChannelMessage incoming,
) {
for (int i = messages.length - 1; i >= 0; i--) {
final existing = messages[i];
if (_isChannelRepeat(existing, incoming)) {
@ -2837,9 +2983,10 @@ class MeshCoreConnector extends ChangeNotifier {
bool _isChannelRepeat(ChannelMessage existing, ChannelMessage incoming) {
if (existing.text != incoming.text) return false;
final diffMs = (existing.timestamp.millisecondsSinceEpoch -
incoming.timestamp.millisecondsSinceEpoch)
.abs();
final diffMs =
(existing.timestamp.millisecondsSinceEpoch -
incoming.timestamp.millisecondsSinceEpoch)
.abs();
if (diffMs > 5000) return false;
if (existing.senderName == incoming.senderName) return true;
@ -3013,17 +3160,15 @@ class _RawPacket {
required this.payload,
});
bool get isFlood => routeType == _routeFlood || routeType == _routeTransportFlood;
bool get isFlood =>
routeType == _routeFlood || routeType == _routeTransportFlood;
}
class _ParsedText {
final String senderName;
final String text;
_ParsedText({
required this.senderName,
required this.text,
});
_ParsedText({required this.senderName, required this.text});
}
class _RepeaterAckContext {

View file

@ -20,7 +20,8 @@ class BufferReader {
Uint8List readRemainingBytes() => readBytes(remaining);
String readString() => utf8.decode(readRemainingBytes(), allowMalformed: true);
String readString() =>
utf8.decode(readRemainingBytes(), allowMalformed: true);
String readCString(int maxLength) {
final value = <int>[];
@ -38,13 +39,19 @@ class BufferReader {
int readUInt8() => readBytes(1).buffer.asByteData().getUint8(0);
int readInt8() => readBytes(1).buffer.asByteData().getInt8(0);
int readUInt16LE() => readBytes(2).buffer.asByteData().getUint16(0, Endian.little);
int readUInt16BE() => readBytes(2).buffer.asByteData().getUint16(0, Endian.big);
int readUInt32LE() => readBytes(4).buffer.asByteData().getUint32(0, Endian.little);
int readUInt32BE() => readBytes(4).buffer.asByteData().getUint32(0, Endian.big);
int readInt16LE() => readBytes(2).buffer.asByteData().getInt16(0, Endian.little);
int readUInt16LE() =>
readBytes(2).buffer.asByteData().getUint16(0, Endian.little);
int readUInt16BE() =>
readBytes(2).buffer.asByteData().getUint16(0, Endian.big);
int readUInt32LE() =>
readBytes(4).buffer.asByteData().getUint32(0, Endian.little);
int readUInt32BE() =>
readBytes(4).buffer.asByteData().getUint32(0, Endian.big);
int readInt16LE() =>
readBytes(2).buffer.asByteData().getInt16(0, Endian.little);
int readInt16BE() => readBytes(2).buffer.asByteData().getInt16(0, Endian.big);
int readInt32LE() => readBytes(4).buffer.asByteData().getInt32(0, Endian.little);
int readInt32LE() =>
readBytes(4).buffer.asByteData().getInt32(0, Endian.little);
int readInt24BE() {
var value = (readByte() << 16) | (readByte() << 8) | readByte();
@ -63,21 +70,25 @@ class BufferWriter {
void writeBytes(Uint8List bytes) => _builder.add(bytes);
void writeUInt16LE(int num) {
final bytes = Uint8List(2)..buffer.asByteData().setUint16(0, num, Endian.little);
final bytes = Uint8List(2)
..buffer.asByteData().setUint16(0, num, Endian.little);
writeBytes(bytes);
}
void writeUInt32LE(int num) {
final bytes = Uint8List(4)..buffer.asByteData().setUint32(0, num, Endian.little);
final bytes = Uint8List(4)
..buffer.asByteData().setUint32(0, num, Endian.little);
writeBytes(bytes);
}
void writeInt32LE(int num) {
final bytes = Uint8List(4)..buffer.asByteData().setInt32(0, num, Endian.little);
final bytes = Uint8List(4)
..buffer.asByteData().setInt32(0, num, Endian.little);
writeBytes(bytes);
}
void writeString(String string) => writeBytes(Uint8List.fromList(utf8.encode(string)));
void writeString(String string) =>
writeBytes(Uint8List.fromList(utf8.encode(string)));
void writeCString(String string, int maxLength) {
final bytes = Uint8List(maxLength);
@ -118,6 +129,7 @@ const int cmdGetChannel = 31;
const int cmdSetChannel = 32;
const int cmdGetRadioSettings = 57;
const int cmdGetTelemetryReq = 39;
const int cmdSetCustomVar = 41;
const int cmdSendBinaryReq = 50;
// Text message types
@ -166,7 +178,6 @@ const int pushCodeNewAdvert = 0x8A;
const int pushCodeTelemetryResponse = 0x8B;
const int pushCodeBinaryResponse = 0x8C;
// Contact/advertisement types
const int advTypeChat = 1;
const int advTypeRepeater = 2;
@ -233,10 +244,7 @@ class ParsedContactText {
final Uint8List senderPrefix;
final String text;
const ParsedContactText({
required this.senderPrefix,
required this.text,
});
const ParsedContactText({required this.senderPrefix, required this.text});
}
ParsedContactText? parseContactMessageText(Uint8List frame) {
@ -265,10 +273,17 @@ ParsedContactText? parseContactMessageText(Uint8List frame) {
return null;
}
var text = readCString(frame, baseTextOffset, frame.length - baseTextOffset).trim();
var text = readCString(
frame,
baseTextOffset,
frame.length - baseTextOffset,
).trim();
if (text.isEmpty && frame.length > baseTextOffset + 4) {
text =
readCString(frame, baseTextOffset + 4, frame.length - (baseTextOffset + 4)).trim();
text = readCString(
frame,
baseTextOffset + 4,
frame.length - (baseTextOffset + 4),
).trim();
}
if (text.isEmpty) return null;
@ -362,7 +377,8 @@ Uint8List buildSendTextMsgFrame(
int attempt = 0,
int? timestampSeconds,
}) {
final timestamp = timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000);
final timestamp =
timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000);
final writer = BufferWriter();
writer.writeByte(cmdSendTxtMsg);
writer.writeByte(txtTypePlain);
@ -444,7 +460,9 @@ Uint8List buildSendSelfAdvertFrame({bool flood = false}) {
// Format: [cmd][name...]
Uint8List buildSetAdvertNameFrame(String name) {
final nameBytes = utf8.encode(name);
final nameLen = nameBytes.length < maxNameSize ? nameBytes.length : maxNameSize - 1;
final nameLen = nameBytes.length < maxNameSize
? nameBytes.length
: maxNameSize - 1;
final writer = BufferWriter();
writer.writeByte(cmdSetAdvertName);
writer.writeBytes(Uint8List.fromList(nameBytes.sublist(0, nameLen)));
@ -461,6 +479,14 @@ Uint8List buildSetAdvertLatLonFrame(double lat, double lon) {
return writer.toBytes();
}
Uint8List buildSetCustomVarFrame(String value) {
final writer = BufferWriter();
writer.writeByte(cmdSetCustomVar);
writer.writeString(value);
writer.writeByte(0);
return writer.toBytes();
}
// Build CMD_REBOOT frame
// Format: [cmd]["reboot"]
Uint8List buildRebootFrame() {
@ -544,7 +570,9 @@ Uint8List buildUpdateContactPathFrame(
// Path data (64 bytes, zero-padded)
final pathPadded = Uint8List(maxPathSize);
if (customPath.isNotEmpty && pathLen > 0) {
final copyLen = customPath.length < maxPathSize ? customPath.length : maxPathSize;
final copyLen = customPath.length < maxPathSize
? customPath.length
: maxPathSize;
for (int i = 0; i < copyLen; i++) {
pathPadded[i] = customPath[i];
}
@ -598,9 +626,11 @@ int calculateLoRaAirtime({
final crc = 1; // CRC enabled
final de = lowDataRateOptimize ? 1 : 0;
final numerator = 8 * payloadBytes - 4 * spreadingFactor + 28 + 16 * crc - headerBytes;
final numerator =
8 * payloadBytes - 4 * spreadingFactor + 28 + 16 * crc - headerBytes;
final denominator = 4 * (spreadingFactor - 2 * de);
var payloadSymbols = 8 + ((numerator / denominator).ceil()) * (codingRate + 4);
var payloadSymbols =
8 + ((numerator / denominator).ceil()) * (codingRate + 4);
if (payloadSymbols < 0) {
payloadSymbols = 8;
@ -647,7 +677,8 @@ Uint8List buildSendCliCommandFrame(
int attempt = 0,
int? timestampSeconds,
}) {
final timestamp = timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000);
final timestamp =
timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000);
final writer = BufferWriter();
writer.writeByte(cmdSendTxtMsg);
writer.writeByte(txtTypeCliData);
@ -661,10 +692,7 @@ Uint8List buildSendCliCommandFrame(
// Build a telemetry request frame
// Format: [cmd][pub_key x32][payload]
Uint8List buildSendBinaryReq(
Uint8List repeaterPubKey, {
Uint8List? payload,
}) {
Uint8List buildSendBinaryReq(Uint8List repeaterPubKey, {Uint8List? payload}) {
final writer = BufferWriter();
writer.writeByte(cmdSendBinaryReq);
writer.writeBytes(repeaterPubKey);
@ -672,4 +700,4 @@ Uint8List buildSendBinaryReq(
writer.writeBytes(payload);
}
return writer.toBytes();
}
}