diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 8b32870..96fb229 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -66,8 +66,10 @@ class MeshCoreConnector extends ChangeNotifier { final Map> _conversations = {}; final Map> _channelMessages = {}; final Set _loadedConversationKeys = {}; - final Map> _processedChannelReactions = {}; // channelIndex -> Set of "reactionKey_emoji" - final Map> _processedContactReactions = {}; // contactPubKeyHex -> Set of "reactionKey_emoji" + final Map> _processedChannelReactions = + {}; // channelIndex -> Set of "reactionKey_emoji" + final Map> _processedContactReactions = + {}; // contactPubKeyHex -> Set of "reactionKey_emoji" StreamSubscription>? _scanSubscription; StreamSubscription? _connectionSubscription; @@ -137,7 +139,8 @@ class MeshCoreConnector extends ChangeNotifier { final ContactStore _contactStore = ContactStore(); final UnreadStore _unreadStore = UnreadStore(); final Map _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 _contactSmazEnabled = {}; final Set _knownContactKeys = {}; final Map _contactLastReadMs = {}; @@ -166,6 +169,7 @@ class MeshCoreConnector extends ChangeNotifier { } return 'Unknown Device'; } + List get scanResults => List.unmodifiable(_scanResults); List get contacts { final selfKey = _selfPublicKey; @@ -176,6 +180,7 @@ class MeshCoreConnector extends ChangeNotifier { _contacts.where((contact) => !listEquals(contact.publicKey, selfKey)), ); } + List 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 _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 startScan({Duration timeout = const Duration(seconds: 10)}) async { + Future 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 getContacts({int? since, bool preserveExisting = false}) async { @@ -979,10 +999,7 @@ class MeshCoreConnector extends ChangeNotifier { } Future refreshContactsSinceLastmod() async { - await getContacts( - since: _latestContactLastmod(), - preserveExisting: true, - ); + await getContacts(since: _latestContactLastmod(), preserveExisting: true); } Future getContactByKey(Uint8List pubKey) async { @@ -990,18 +1007,20 @@ class MeshCoreConnector extends ChangeNotifier { await sendFrame(buildGetContactByKeyFrame(pubKey)); } - Future sendMessage( - Contact contact, - String text, - ) async { + Future 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 sendChannelMessage(Channel channel, String text) async{ + Future 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.from(_contactLastReadMs), - ); + _unreadStore.saveContactLastRead(Map.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 setNodeLocation({required double lat, required double lon}) async { + Future setNodeLocation({ + required double lat, + required double lon, + }) async { if (!isConnected) return; await sendFrame(buildSetAdvertLatLonFrame(lat, lon)); } + Future setCustomVar(String value) async { + if (!isConnected) return; + await sendFrame(buildSetCustomVarFrame(value)); + } + Future 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.from(_channelLastReadMs), - ); + _unreadStore.saveChannelLastRead(Map.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.from(_contactLastReadMs), - ); + _unreadStore.saveContactLastRead(Map.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.from(_channelLastReadMs), - ); + _unreadStore.saveChannelLastRead(Map.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 messages, ReactionInfo reactionInfo) { + void _processContactReaction( + List 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 messages, String mentionedNode) { + ChannelMessage? _findMessageBySender( + List 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 messages, ReactionInfo reactionInfo) { + void _processReaction( + List 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 messages, ChannelMessage incoming) { + int _findChannelRepeatIndex( + List 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 { diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index c828a1a..21c4392 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -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 = []; @@ -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(); -} \ No newline at end of file +}