upgraded flutter and other fixes

This commit is contained in:
zach
2025-12-31 22:19:48 -07:00
parent be97e5c7fc
commit 44be6cd5e7
24 changed files with 2082 additions and 442 deletions
+1292
View File
File diff suppressed because it is too large Load Diff
+289 -47
View File
@@ -1,13 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:crypto/crypto.dart' as crypto; import 'package:crypto/crypto.dart' as crypto;
import 'package:pointycastle/export.dart'; import 'package:pointycastle/export.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:uuid/uuid.dart'; import 'package:wakelock_plus/wakelock_plus.dart';
import '../models/channel.dart'; import '../models/channel.dart';
import '../models/channel_message.dart'; import '../models/channel_message.dart';
@@ -66,6 +64,8 @@ class MeshCoreConnector extends ChangeNotifier {
final Map<String, List<Message>> _conversations = {}; final Map<String, List<Message>> _conversations = {};
final Map<int, List<ChannelMessage>> _channelMessages = {}; final Map<int, List<ChannelMessage>> _channelMessages = {};
final Set<String> _loadedConversationKeys = {}; 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"
StreamSubscription<List<ScanResult>>? _scanSubscription; StreamSubscription<List<ScanResult>>? _scanSubscription;
StreamSubscription<BluetoothConnectionState>? _connectionSubscription; StreamSubscription<BluetoothConnectionState>? _connectionSubscription;
@@ -101,6 +101,10 @@ class MeshCoreConnector extends ChangeNotifier {
bool _queuedMessageSyncInFlight = false; bool _queuedMessageSyncInFlight = false;
bool _didInitialQueueSync = false; bool _didInitialQueueSync = false;
bool _pendingQueueSync = false; bool _pendingQueueSync = false;
Timer? _queueSyncTimeout;
int _queueSyncRetries = 0;
static const int _maxQueueSyncRetries = 3;
static const int _queueSyncTimeoutMs = 5000; // 5 second timeout
// Services // Services
MessageRetryService? _retryService; MessageRetryService? _retryService;
@@ -316,6 +320,19 @@ class MeshCoreConnector extends ChangeNotifier {
return count; return count;
} }
int getTotalUnreadCount() {
var total = 0;
// Count unread contact messages
for (final contact in _contacts) {
total += getUnreadCountForContact(contact);
}
// Count unread channel messages
for (final channelIndex in _channelMessages.keys) {
total += getUnreadCountForChannelIndex(channelIndex);
}
return total;
}
bool isChannelSmazEnabled(int channelIndex) { bool isChannelSmazEnabled(int channelIndex) {
return _channelSmazEnabled[channelIndex] ?? false; return _channelSmazEnabled[channelIndex] ?? false;
} }
@@ -675,6 +692,9 @@ class MeshCoreConnector extends ChangeNotifier {
_setState(MeshCoreConnectionState.connected); _setState(MeshCoreConnectionState.connected);
// Enable wake lock to prevent BLE disconnection when screen turns off
await WakelockPlus.enable();
await _requestDeviceInfo(); await _requestDeviceInfo();
final gotSelfInfo = await _waitForSelfInfo( final gotSelfInfo = await _waitForSelfInfo(
timeout: const Duration(seconds: 3), timeout: const Duration(seconds: 3),
@@ -778,6 +798,9 @@ class MeshCoreConnector extends ChangeNotifier {
} }
_setState(MeshCoreConnectionState.disconnecting); _setState(MeshCoreConnectionState.disconnecting);
// Disable wake lock when disconnecting
await WakelockPlus.disable();
await _notifySubscription?.cancel(); await _notifySubscription?.cancel();
_notifySubscription = null; _notifySubscription = null;
@@ -785,6 +808,9 @@ class MeshCoreConnector extends ChangeNotifier {
_connectionSubscription = null; _connectionSubscription = null;
_selfInfoRetryTimer?.cancel(); _selfInfoRetryTimer?.cancel();
_selfInfoRetryTimer = null; _selfInfoRetryTimer = null;
_queueSyncTimeout?.cancel();
_queueSyncTimeout = null;
_queueSyncRetries = 0;
try { try {
// Skip queued BLE operations so disconnect doesn't get stuck behind them. // Skip queued BLE operations so disconnect doesn't get stuck behind them.
@@ -912,16 +938,20 @@ class MeshCoreConnector extends ChangeNotifier {
); );
} }
Future<void> getContactByKey(Uint8List pubKey) async {
if (!isConnected) return;
await sendFrame(buildGetContactByKeyFrame(pubKey));
}
Future<void> sendMessage( Future<void> sendMessage(
Contact contact, Contact contact,
String text, { String text,
bool clearPath = false, ) async {
}) async {
if (!isConnected || text.isEmpty) return; if (!isConnected || text.isEmpty) return;
// Handle auto-rotation if enabled // Handle auto-rotation if enabled
PathSelection? autoSelection; PathSelection? autoSelection;
if (_appSettingsService?.settings.autoRouteRotationEnabled == true && !clearPath) { if (_appSettingsService?.settings.autoRouteRotationEnabled == true) {
autoSelection = _pathHistoryService?.getNextAutoPathSelection(contact.publicKeyHex); autoSelection = _pathHistoryService?.getNextAutoPathSelection(contact.publicKeyHex);
if (autoSelection != null) { if (autoSelection != null) {
_pathHistoryService?.recordPathAttempt(contact.publicKeyHex, autoSelection); _pathHistoryService?.recordPathAttempt(contact.publicKeyHex, autoSelection);
@@ -936,21 +966,20 @@ class MeshCoreConnector extends ChangeNotifier {
} }
if (_retryService != null) { if (_retryService != null) {
final pathBytes = _resolveOutgoingPathBytes(contact, clearPath, autoSelection); final pathBytes = _resolveOutgoingPathBytes(contact, autoSelection);
final pathLength = _resolveOutgoingPathLength(contact, clearPath, autoSelection); final pathLength = _resolveOutgoingPathLength(contact, autoSelection);
final selectedContact = _applyAutoSelection(contact, autoSelection); final selectedContact = _applyAutoSelection(contact, autoSelection);
await _retryService!.sendMessageWithRetry( await _retryService!.sendMessageWithRetry(
contact: selectedContact, contact: selectedContact,
text: text, text: text,
clearPath: clearPath,
pathSelection: autoSelection, pathSelection: autoSelection,
pathBytes: pathBytes, pathBytes: pathBytes,
pathLength: pathLength, pathLength: pathLength,
); );
} else { } else {
// Fallback to old behavior if retry service not initialized // Fallback to old behavior if retry service not initialized
final pathBytes = _resolveOutgoingPathBytes(contact, clearPath, autoSelection); final pathBytes = _resolveOutgoingPathBytes(contact, autoSelection);
final pathLength = _resolveOutgoingPathLength(contact, clearPath, autoSelection); final pathLength = _resolveOutgoingPathLength(contact, autoSelection);
final message = Message.outgoing( final message = Message.outgoing(
contact.publicKey, contact.publicKey,
text, text,
@@ -985,9 +1014,72 @@ class MeshCoreConnector extends ChangeNotifier {
)); ));
} }
/// Set path override for a contact (persists across contact refreshes)
/// pathLen: -1 = force flood, null = auto (use device path), >= 0 = specific path
Future<void> setPathOverride(
Contact contact, {
int? pathLen,
Uint8List? pathBytes,
}) async {
// Find contact in list
final index = _contacts.indexWhere((c) => c.publicKeyHex == contact.publicKeyHex);
if (index == -1) return;
// Update contact with new path override
_contacts[index] = _contacts[index].copyWith(
pathOverride: pathLen,
pathOverrideBytes: pathBytes,
clearPathOverride: pathLen == null, // Clear if pathLen is null
);
// Save to storage
await _contactStore.saveContacts(_contacts);
// If setting a specific path (not flood, not auto), also sync with device
if (pathLen != null && pathLen >= 0 && pathBytes != null) {
await setContactPath(contact, pathBytes, pathLen);
}
debugPrint('Set path override for ${contact.name}: pathLen=$pathLen, bytes=${pathBytes?.length ?? 0}');
notifyListeners();
}
Future<void> sendChannelMessage(Channel channel, String text) async{ Future<void> sendChannelMessage(Channel channel, String text) async{
if (!isConnected || text.isEmpty) return; if (!isConnected || text.isEmpty) return;
// Check if this is a reaction - if so, process it immediately instead of adding as a message
final reactionInfo = ReactionHelper.parseReaction(text);
if (reactionInfo != null) {
// Check if we've already processed this reaction
_processedChannelReactions.putIfAbsent(channel.index, () => {});
final reactionKey = reactionInfo.reactionKey;
final reactionIdentifier = reactionKey != null ? '${reactionKey}_${reactionInfo.emoji}' : null;
if (reactionIdentifier != null && _processedChannelReactions[channel.index]!.contains(reactionIdentifier)) {
// Already processed, don't process again
return;
}
// Get the in-memory messages list (same as _addChannelMessage uses)
_channelMessages.putIfAbsent(channel.index, () => []);
final messages = _channelMessages[channel.index]!;
// Process reaction locally to update the UI immediately
_processReaction(messages, reactionInfo);
await _channelMessageStore.saveChannelMessages(channel.index, messages);
// Mark this reaction as processed
if (reactionIdentifier != null) {
_processedChannelReactions[channel.index]!.add(reactionIdentifier);
}
notifyListeners();
// Send the reaction to the device (don't add as a visible message)
await sendFrame(buildSendChannelTextMsgFrame(channel.index, text));
return;
}
final message = ChannelMessage.outgoing(text, _selfName ?? 'Me', channel.index); final message = ChannelMessage.outgoing(text, _selfName ?? 'Me', channel.index);
_addChannelMessage(channel.index, message); _addChannelMessage(channel.index, message);
notifyListeners(); notifyListeners();
@@ -1025,16 +1117,10 @@ class MeshCoreConnector extends ChangeNotifier {
_contacts.indexWhere((c) => c.publicKeyHex == contact.publicKeyHex); _contacts.indexWhere((c) => c.publicKeyHex == contact.publicKeyHex);
if (existingIndex >= 0) { if (existingIndex >= 0) {
final existing = _contacts[existingIndex]; final existing = _contacts[existingIndex];
_contacts[existingIndex] = Contact( // Use copyWith to preserve pathOverride and pathOverrideBytes
publicKey: existing.publicKey, _contacts[existingIndex] = existing.copyWith(
name: existing.name,
type: existing.type,
pathLength: -1, pathLength: -1,
path: Uint8List(0), path: Uint8List(0),
latitude: existing.latitude,
longitude: existing.longitude,
lastSeen: existing.lastSeen,
lastMessageAt: existing.lastMessageAt,
); );
notifyListeners(); notifyListeners();
unawaited(_persistContacts()); unawaited(_persistContacts());
@@ -1082,15 +1168,47 @@ class MeshCoreConnector extends ChangeNotifier {
if (!isConnected) { if (!isConnected) {
_isSyncingQueuedMessages = false; _isSyncingQueuedMessages = false;
_queuedMessageSyncInFlight = false; _queuedMessageSyncInFlight = false;
_queueSyncRetries = 0;
return; return;
} }
if (_queuedMessageSyncInFlight) return; if (_queuedMessageSyncInFlight) return;
_queuedMessageSyncInFlight = true; _queuedMessageSyncInFlight = true;
// Cancel any existing timeout
_queueSyncTimeout?.cancel();
// Set up timeout for this request
_queueSyncTimeout = Timer(Duration(milliseconds: _queueSyncTimeoutMs), () {
_handleQueueSyncTimeout();
});
debugPrint('[QueueSync] Requesting next message (retry: $_queueSyncRetries/$_maxQueueSyncRetries)');
try { try {
await sendFrame(buildSyncNextMessageFrame()); await sendFrame(buildSyncNextMessageFrame());
} catch (e) { } catch (e) {
debugPrint('[QueueSync] Error sending sync request: $e');
_queuedMessageSyncInFlight = false; _queuedMessageSyncInFlight = false;
_isSyncingQueuedMessages = false; _isSyncingQueuedMessages = false;
_queueSyncTimeout?.cancel();
_queueSyncRetries = 0;
}
}
void _handleQueueSyncTimeout() {
debugPrint('[QueueSync] Timeout waiting for message (retry: $_queueSyncRetries/$_maxQueueSyncRetries)');
if (_queueSyncRetries < _maxQueueSyncRetries) {
// Retry
_queueSyncRetries++;
_queuedMessageSyncInFlight = false;
_requestNextQueuedMessage();
} else {
// Max retries reached, give up
debugPrint('[QueueSync] Max retries reached, stopping sync');
_queuedMessageSyncInFlight = false;
_isSyncingQueuedMessages = false;
_queueSyncRetries = 0;
} }
} }
@@ -1128,22 +1246,51 @@ class MeshCoreConnector extends ChangeNotifier {
await sendCliCommand('set privacy ${enabled ? 'on' : 'off'}'); await sendCliCommand('set privacy ${enabled ? 'on' : 'off'}');
} }
final Set<int> _expectedChannelIndices = {};
Future<void> getChannels({int? maxChannels}) async { Future<void> getChannels({int? maxChannels}) async {
if (!isConnected) return; if (!isConnected) return;
_isLoadingChannels = true; _isLoadingChannels = true;
final previousChannels = List<Channel>.from(_channels);
_channels.clear(); _channels.clear();
_expectedChannelIndices.clear();
notifyListeners(); notifyListeners();
// Request each channel index // Request each channel index (send all requests in parallel)
final channelCount = maxChannels ?? _maxChannels; final channelCount = maxChannels ?? _maxChannels;
for (int i = 0; i < channelCount; i++) { for (int i = 0; i < channelCount; i++) {
await sendFrame(buildGetChannelFrame(i)); _expectedChannelIndices.add(i);
sendFrame(buildGetChannelFrame(i)); // No await - send all at once
} }
// Wait a bit for responses to arrive, then apply final sort // Wait for responses with timeout
await Future.delayed(const Duration(seconds: 2)); final stopwatch = Stopwatch()..start();
const maxWaitTime = Duration(seconds: 5);
const checkInterval = Duration(milliseconds: 100);
while (_expectedChannelIndices.isNotEmpty && stopwatch.elapsed < maxWaitTime) {
await Future.delayed(checkInterval);
}
stopwatch.stop();
// If timeout expired and we're still missing channels, restore them from previous load
if (_expectedChannelIndices.isNotEmpty) {
debugPrint('Channel loading timeout - missing ${_expectedChannelIndices.length} channels, restoring from cache');
for (final prevChannel in previousChannels) {
if (_expectedChannelIndices.contains(prevChannel.index) &&
!_channels.any((c) => c.index == prevChannel.index)) {
_channels.add(prevChannel);
debugPrint('Restored channel ${prevChannel.index} (${prevChannel.name}) from cache');
}
}
}
debugPrint('Channel loading completed: received ${_channels.length}/$channelCount channels in ${stopwatch.elapsedMilliseconds}ms');
_isLoadingChannels = false; _isLoadingChannels = false;
_expectedChannelIndices.clear();
_applyChannelOrder(); _applyChannelOrder();
notifyListeners(); notifyListeners();
} }
@@ -1266,7 +1413,10 @@ class MeshCoreConnector extends ChangeNotifier {
if (contact != null) { if (contact != null) {
_pathHistoryService!.handlePathUpdated(contact); _pathHistoryService!.handlePathUpdated(contact);
refreshContactsSinceLastmod(); // Refresh just this specific contact instead of all contacts.
// This avoids race conditions with _preserveContactsOnRefresh flag
// that can occur when using refreshContactsSinceLastmod().
getContactByKey(pubKey);
} }
} }
} }
@@ -1341,13 +1491,19 @@ class MeshCoreConnector extends ChangeNotifier {
} }
void _handleNoMoreMessages() { void _handleNoMoreMessages() {
debugPrint('[QueueSync] No more messages, sync complete');
_queueSyncTimeout?.cancel();
_isSyncingQueuedMessages = false; _isSyncingQueuedMessages = false;
_queuedMessageSyncInFlight = false; _queuedMessageSyncInFlight = false;
_queueSyncRetries = 0; // Reset retry counter on successful completion
} }
void _handleQueuedMessageReceived() { void _handleQueuedMessageReceived() {
if (!_isSyncingQueuedMessages) return; if (!_isSyncingQueuedMessages) return;
debugPrint('[QueueSync] Message received, requesting next');
_queueSyncTimeout?.cancel(); // Cancel timeout - message arrived
_queuedMessageSyncInFlight = false; _queuedMessageSyncInFlight = false;
_queueSyncRetries = 0; // Reset retry counter on successful message
unawaited(_requestNextQueuedMessage()); unawaited(_requestNextQueuedMessage());
} }
@@ -1432,8 +1588,11 @@ class MeshCoreConnector extends ChangeNotifier {
final mergedLastMessageAt = existing.lastMessageAt.isAfter(contact.lastMessageAt) final mergedLastMessageAt = existing.lastMessageAt.isAfter(contact.lastMessageAt)
? existing.lastMessageAt ? existing.lastMessageAt
: contact.lastMessageAt; : contact.lastMessageAt;
// CRITICAL: Preserve user's path override when contact is refreshed from device
_contacts[existingIndex] = contact.copyWith( _contacts[existingIndex] = contact.copyWith(
lastMessageAt: mergedLastMessageAt, lastMessageAt: mergedLastMessageAt,
pathOverride: existing.pathOverride, // Preserve user's path choice
pathOverrideBytes: existing.pathOverrideBytes,
); );
} else { } else {
_contacts.add(contact); _contacts.add(contact);
@@ -1559,10 +1718,28 @@ class MeshCoreConnector extends ChangeNotifier {
return false; return false;
} }
void _handleIncomingMessage(Uint8List frame) { void _handleIncomingMessage(Uint8List frame) async {
if (_selfPublicKey == null) return; if (_selfPublicKey == null) return;
var message = _parseContactMessage(frame); var message = _parseContactMessage(frame);
// If message parsing failed due to unknown contact, refresh contacts and retry
if (message == null && !_isLoadingContacts) {
final senderPrefix = _extractSenderPrefix(frame);
if (senderPrefix != null) {
final hasContact = _contacts.any((c) => _matchesPrefix(c.publicKey, senderPrefix));
if (!hasContact) {
debugPrint('Received message from unknown contact, refreshing contacts...');
await refreshContactsSinceLastmod();
// Retry parsing after refresh
message = _parseContactMessage(frame);
if (message != null) {
debugPrint('Successfully parsed message after contact refresh');
}
}
}
}
if (message != null) { if (message != null) {
final contact = _contacts.cast<Contact?>().firstWhere( final contact = _contacts.cast<Contact?>().firstWhere(
(c) => c?.publicKeyHex == message!.senderKeyHex, (c) => c?.publicKeyHex == message!.senderKeyHex,
@@ -1605,6 +1782,7 @@ class MeshCoreConnector extends ChangeNotifier {
contactName: contact?.name ?? 'Unknown', contactName: contact?.name ?? 'Unknown',
message: message.text, message: message.text,
contactId: message.senderKeyHex, contactId: message.senderKeyHex,
badgeCount: getTotalUnreadCount(),
); );
} }
} }
@@ -1680,6 +1858,21 @@ class MeshCoreConnector extends ChangeNotifier {
return true; return true;
} }
Uint8List? _extractSenderPrefix(Uint8List frame) {
if (frame.isEmpty) return null;
final code = frame[0];
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
return null;
}
final prefixOffset = code == respCodeContactMsgRecvV3 ? 4 : 1;
const prefixLen = 6;
if (frame.length < prefixOffset + prefixLen) return null;
return frame.sublist(prefixOffset, prefixOffset + prefixLen);
}
void _ensureContactSmazSettingLoaded(String contactKeyHex) { void _ensureContactSmazSettingLoaded(String contactKeyHex) {
if (_contactSmazEnabled.containsKey(contactKeyHex)) return; if (_contactSmazEnabled.containsKey(contactKeyHex)) return;
_contactSettingsStore.loadSmazEnabled(contactKeyHex).then((enabled) { _contactSettingsStore.loadSmazEnabled(contactKeyHex).then((enabled) {
@@ -1729,6 +1922,7 @@ class MeshCoreConnector extends ChangeNotifier {
channelName: label, channelName: label,
message: message.text, message: message.text,
channelIndex: channelIndex, channelIndex: channelIndex,
badgeCount: getTotalUnreadCount(),
); );
} }
@@ -1884,14 +2078,21 @@ class MeshCoreConnector extends ChangeNotifier {
void _handleChannelInfo(Uint8List frame) { void _handleChannelInfo(Uint8List frame) {
final channel = Channel.fromFrame(frame); final channel = Channel.fromFrame(frame);
if (channel != null && !channel.isEmpty) { if (channel != null) {
_channels.add(channel); // Mark this channel index as received
_expectedChannelIndices.remove(channel.index);
// Only add non-empty channels to the list
if (!channel.isEmpty) {
_channels.add(channel);
}
// Only sort and notify if we're not currently loading channels // Only sort and notify if we're not currently loading channels
// This prevents the list from jumping around as channels arrive during refresh // This prevents the list from jumping around as channels arrive during refresh
if (!_isLoadingChannels) { if (!_isLoadingChannels) {
_applyChannelOrder(); _applyChannelOrder();
notifyListeners();
} }
notifyListeners();
} }
} }
@@ -1999,17 +2200,24 @@ class MeshCoreConnector extends ChangeNotifier {
// Parse reaction info // Parse reaction info
final reactionInfo = Message.parseReaction(message.text); final reactionInfo = Message.parseReaction(message.text);
if (reactionInfo != null) { if (reactionInfo != null) {
// Check if we've already processed this exact reaction // Check if we've already processed this exact reaction using lightweight key
final isDuplicate = messages.any((m) => _processedContactReactions.putIfAbsent(pubKeyHex, () => {});
m.text == message.text && final reactionKey = reactionInfo.reactionKey;
m.senderKey == message.senderKey && final reactionIdentifier = reactionKey != null ? '${reactionKey}_${reactionInfo.emoji}' : null;
m.timestamp.millisecondsSinceEpoch == message.timestamp.millisecondsSinceEpoch
); final isDuplicate = reactionIdentifier != null &&
_processedContactReactions[pubKeyHex]!.contains(reactionIdentifier);
if (!isDuplicate) { if (!isDuplicate) {
// New reaction - process it // New reaction - process it
_processContactReaction(messages, reactionInfo); _processContactReaction(messages, reactionInfo);
_messageStore.saveMessages(pubKeyHex, messages); _messageStore.saveMessages(pubKeyHex, messages);
// Mark as processed
if (reactionIdentifier != null) {
_processedContactReactions[pubKeyHex]!.add(reactionIdentifier);
}
notifyListeners(); notifyListeners();
} }
return; // Don't add reaction as a visible message return; // Don't add reaction as a visible message
@@ -2115,29 +2323,50 @@ class MeshCoreConnector extends ChangeNotifier {
Uint8List _resolveOutgoingPathBytes( Uint8List _resolveOutgoingPathBytes(
Contact contact, Contact contact,
bool clearPath,
PathSelection? selection, PathSelection? selection,
) { ) {
if (clearPath || contact.pathLength < 0 || selection?.useFlood == true) { // Priority 1: Check user's path override
if (contact.pathOverride != null) {
if (contact.pathOverride! < 0) {
return Uint8List(0); // Force flood
}
return contact.pathOverrideBytes ?? Uint8List(0);
}
// Priority 2: Check device flood mode or PathSelection flood
if (contact.pathLength < 0 || selection?.useFlood == true) {
return Uint8List(0); return Uint8List(0);
} }
// Priority 3: Check PathSelection (auto-rotation)
if (selection != null && selection.pathBytes.isNotEmpty) { if (selection != null && selection.pathBytes.isNotEmpty) {
return Uint8List.fromList(selection.pathBytes); return Uint8List.fromList(selection.pathBytes);
} }
// Priority 4: Use device's discovered path
return contact.path; return contact.path;
} }
int? _resolveOutgoingPathLength( int? _resolveOutgoingPathLength(
Contact contact, Contact contact,
bool clearPath,
PathSelection? selection, PathSelection? selection,
) { ) {
if (clearPath || contact.pathLength < 0 || selection?.useFlood == true) { // Priority 1: Check user's path override
if (contact.pathOverride != null) {
return contact.pathOverride;
}
// Priority 2: Check device flood mode or PathSelection flood
if (contact.pathLength < 0 || selection?.useFlood == true) {
return -1; return -1;
} }
// Priority 3: Check PathSelection (auto-rotation)
if (selection != null && selection.pathBytes.isNotEmpty) { if (selection != null && selection.pathBytes.isNotEmpty) {
return selection.hopCount; return selection.hopCount;
} }
// Priority 4: Use device's discovered path
return contact.pathLength; return contact.pathLength;
} }
@@ -2148,19 +2377,24 @@ class MeshCoreConnector extends ChangeNotifier {
// Parse reaction info // Parse reaction info
final reactionInfo = ChannelMessage.parseReaction(message.text); final reactionInfo = ChannelMessage.parseReaction(message.text);
if (reactionInfo != null) { if (reactionInfo != null) {
// Check if we've already processed this exact reaction by looking for duplicate in messages // Check if we've already processed this exact reaction using lightweight key
// Reaction messages are kept in the list but won't be displayed (filtered in UI or here) _processedChannelReactions.putIfAbsent(channelIndex, () => {});
final isDuplicate = messages.any((m) => final reactionKey = reactionInfo.reactionKey;
m.text == message.text && final reactionIdentifier = reactionKey != null ? '${reactionKey}_${reactionInfo.emoji}' : null;
m.senderName == message.senderName &&
m.timestamp.millisecondsSinceEpoch == message.timestamp.millisecondsSinceEpoch final isDuplicate = reactionIdentifier != null &&
); _processedChannelReactions[channelIndex]!.contains(reactionIdentifier);
if (!isDuplicate) { if (!isDuplicate) {
// New reaction - process it // New reaction - process it
_processReaction(messages, reactionInfo); _processReaction(messages, reactionInfo);
// Save updated messages // Save updated messages
_channelMessageStore.saveChannelMessages(channelIndex, messages); _channelMessageStore.saveChannelMessages(channelIndex, messages);
// Mark as processed
if (reactionIdentifier != null) {
_processedChannelReactions[channelIndex]!.add(reactionIdentifier);
}
} }
return false; // Don't add reaction as a visible message return false; // Don't add reaction as a visible message
} }
@@ -2208,11 +2442,16 @@ class MeshCoreConnector extends ChangeNotifier {
processedMessage.pathLength, processedMessage.pathLength,
mergedPathBytes.length, mergedPathBytes.length,
); );
final newRepeatCount = existing.repeatCount + 1;
messages[existingIndex] = existing.copyWith( messages[existingIndex] = existing.copyWith(
repeatCount: existing.repeatCount + 1, repeatCount: newRepeatCount,
pathLength: mergedPathLength, pathLength: mergedPathLength,
pathBytes: mergedPathBytes, pathBytes: mergedPathBytes,
pathVariants: mergedPathVariants, pathVariants: mergedPathVariants,
// Mark as sent when first repeat is heard
status: newRepeatCount == 1 && existing.status == ChannelMessageStatus.pending
? ChannelMessageStatus.sent
: existing.status,
); );
} else { } else {
messages.add(processedMessage); messages.add(processedMessage);
@@ -2357,6 +2596,9 @@ class MeshCoreConnector extends ChangeNotifier {
} }
void _handleDisconnection() { void _handleDisconnection() {
// Disable wake lock when connection is lost
WakelockPlus.disable();
_notifySubscription?.cancel(); _notifySubscription?.cancel();
_notifySubscription = null; _notifySubscription = null;
_connectionSubscription?.cancel(); _connectionSubscription?.cancel();
+10
View File
@@ -25,6 +25,7 @@ const int cmdGetBattAndStorage = 20;
const int cmdDeviceQuery = 22; const int cmdDeviceQuery = 22;
const int cmdSendLogin = 26; const int cmdSendLogin = 26;
const int cmdSendStatusReq = 27; const int cmdSendStatusReq = 27;
const int cmdGetContactByKey = 30;
const int cmdGetChannel = 31; const int cmdGetChannel = 31;
const int cmdSetChannel = 32; const int cmdSetChannel = 32;
const int cmdGetRadioSettings = 57; const int cmdGetRadioSettings = 57;
@@ -507,6 +508,15 @@ Uint8List buildUpdateContactPathFrame(
return frame; return frame;
} }
// Build CMD_GET_CONTACT_BY_KEY frame
// Format: [cmd][pub_key x32]
Uint8List buildGetContactByKeyFrame(Uint8List pubKey) {
final frame = Uint8List(1 + pubKeySize);
frame[0] = cmdGetContactByKey;
frame.setRange(1, 1 + pubKeySize, pubKey);
return frame;
}
// Build CMD_GET_RADIO_SETTINGS frame // Build CMD_GET_RADIO_SETTINGS frame
Uint8List buildGetRadioSettingsFrame() { Uint8List buildGetRadioSettingsFrame() {
return Uint8List.fromList([cmdGetRadioSettings]); return Uint8List.fromList([cmdGetRadioSettings]);
+33 -2
View File
@@ -1,22 +1,53 @@
class ReactionInfo { class ReactionInfo {
final String targetMessageId; final String targetMessageId;
final String emoji; final String emoji;
final String? reactionKey; // Lightweight key for deduplication: timestamp_senderPrefix
ReactionInfo({ ReactionInfo({
required this.targetMessageId, required this.targetMessageId,
required this.emoji, required this.emoji,
this.reactionKey,
}); });
} }
class ReactionHelper { class ReactionHelper {
/// Parse reaction format: r:[messageId]:[emoji] /// Parse reaction format: r:[messageId]:[emoji]
/// Supports both old format (full messageId) and new format (timestamp_senderPrefix)
static ReactionInfo? parseReaction(String text) { static ReactionInfo? parseReaction(String text) {
final regex = RegExp(r'^r:([^:]+):(.+)$'); final regex = RegExp(r'^r:([^:]+):(.+)$');
final match = regex.firstMatch(text); final match = regex.firstMatch(text);
if (match == null) return null; if (match == null) return null;
final targetId = match.group(1)!;
final emoji = match.group(2)!;
// Extract reaction key for deduplication
// If targetId is in new format (timestamp_senderPrefix), use it directly
// Otherwise, extract timestamp from old format (timestamp_nameHash_textHash)
String? reactionKey;
if (targetId.contains('_')) {
final parts = targetId.split('_');
if (parts.length >= 2) {
// New format: timestamp_senderPrefix, or old format with at least timestamp
reactionKey = '${parts[0]}_${parts[1]}';
}
}
return ReactionInfo( return ReactionInfo(
targetMessageId: match.group(1)!, targetMessageId: targetId,
emoji: match.group(2)!, emoji: emoji,
reactionKey: reactionKey,
); );
} }
/// Generate a lightweight reaction key for a message
/// Format: r:[timestamp]_[senderPrefix]:[emoji]
static String buildReactionText(String timestamp, String senderPrefix, String emoji) {
return 'r:${timestamp}_$senderPrefix:$emoji';
}
/// Extract sender prefix from public key hex (first 8 chars)
static String getSenderPrefix(String senderKeyHex) {
return senderKeyHex.substring(0, 8);
}
} }
+11 -2
View File
@@ -5,8 +5,10 @@ class Contact {
final Uint8List publicKey; final Uint8List publicKey;
final String name; final String name;
final int type; final int type;
final int pathLength; // -1 = flood, 0+ = direct hops final int pathLength; // -1 = flood, 0+ = direct hops (from device)
final Uint8List path; final Uint8List path; // Path bytes from device
final int? pathOverride; // User's path override: -1 = force flood, null = auto
final Uint8List? pathOverrideBytes; // User's path override bytes
final double? latitude; final double? latitude;
final double? longitude; final double? longitude;
final DateTime lastSeen; final DateTime lastSeen;
@@ -18,6 +20,8 @@ class Contact {
required this.type, required this.type,
required this.pathLength, required this.pathLength,
required this.path, required this.path,
this.pathOverride,
this.pathOverrideBytes,
this.latitude, this.latitude,
this.longitude, this.longitude,
required this.lastSeen, required this.lastSeen,
@@ -55,6 +59,9 @@ class Contact {
int? type, int? type,
int? pathLength, int? pathLength,
Uint8List? path, Uint8List? path,
int? pathOverride,
Uint8List? pathOverrideBytes,
bool clearPathOverride = false,
double? latitude, double? latitude,
double? longitude, double? longitude,
DateTime? lastSeen, DateTime? lastSeen,
@@ -66,6 +73,8 @@ class Contact {
type: type ?? this.type, type: type ?? this.type,
pathLength: pathLength ?? this.pathLength, pathLength: pathLength ?? this.pathLength,
path: path ?? this.path, path: path ?? this.path,
pathOverride: clearPathOverride ? null : (pathOverride ?? this.pathOverride),
pathOverrideBytes: clearPathOverride ? null : (pathOverrideBytes ?? this.pathOverrideBytes),
latitude: latitude ?? this.latitude, latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude, longitude: longitude ?? this.longitude,
lastSeen: lastSeen ?? this.lastSeen, lastSeen: lastSeen ?? this.lastSeen,
+16 -7
View File
@@ -1,5 +1,4 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -42,6 +41,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
context.read<MeshCoreConnector>().setActiveChannel(widget.channel.index); context.read<MeshCoreConnector>().setActiveChannel(widget.channel.index);
// Scroll to bottom when opening channel chat
if (_scrollController.hasClients) {
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
}
}); });
} }
@@ -352,12 +356,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
Widget contentPreview; Widget contentPreview;
if (gifId != null) { if (gifId != null) {
contentPreview = Row( contentPreview = ClipRRect(
children: [ borderRadius: BorderRadius.circular(4),
Icon(Icons.gif_box, size: 14, color: previewTextColor), child: GifMessage(
const SizedBox(width: 4), url: 'https://media.giphy.com/media/$gifId/giphy.gif',
Text('GIF', style: TextStyle(fontSize: 12, color: previewTextColor)), backgroundColor: colorScheme.surfaceContainerHighest,
], fallbackTextColor: previewTextColor,
width: 120,
height: 80,
),
); );
} else if (poi != null) { } else if (poi != null) {
contentPreview = Row( contentPreview = Row(
@@ -843,6 +850,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
void _sendReaction(ChannelMessage message, String emoji) { void _sendReaction(ChannelMessage message, String emoji) {
final connector = context.read<MeshCoreConnector>(); final connector = context.read<MeshCoreConnector>();
// Send reaction with full messageId to find target, but parser will extract
// lightweight reactionKey (timestamp_senderPrefix) for deduplication
final reactionText = 'r:${message.messageId}:$emoji'; final reactionText = 'r:${message.messageId}:$emoji';
connector.sendChannelMessage(widget.channel, reactionText); connector.sendChannelMessage(widget.channel, reactionText);
} }
+37 -39
View File
@@ -78,48 +78,46 @@ class _ChannelsScreenState extends State<ChannelsScreen>
), ),
], ],
), ),
body: Consumer<MeshCoreConnector>( body: () {
builder: (context, connector, child) { if (connector.isLoadingChannels) {
if (connector.isLoadingChannels) { return const Center(child: CircularProgressIndicator());
return const Center(child: CircularProgressIndicator()); }
}
final channels = connector.channels; final channels = connector.channels;
if (channels.isEmpty) { if (channels.isEmpty) {
return EmptyState( return EmptyState(
icon: Icons.tag, icon: Icons.tag,
title: 'No channels configured', title: 'No channels configured',
action: FilledButton.icon( action: FilledButton.icon(
onPressed: () => _addPublicChannel(context, connector), onPressed: () => _addPublicChannel(context, connector),
icon: const Icon(Icons.public), icon: const Icon(Icons.public),
label: const Text('Add Public Channel'), label: const Text('Add Public Channel'),
),
);
}
return ReorderableListView.builder(
padding: const EdgeInsets.only(left: 16, right: 16, top: 8, bottom: 88),
buildDefaultDragHandles: false,
itemCount: channels.length,
onReorder: (oldIndex, newIndex) {
if (newIndex > oldIndex) newIndex -= 1;
final reordered = List<Channel>.from(channels);
final item = reordered.removeAt(oldIndex);
reordered.insert(newIndex, item);
unawaited(
connector.setChannelOrder(
reordered.map((c) => c.index).toList(),
), ),
); );
} },
itemBuilder: (context, index) {
return ReorderableListView.builder( final channel = channels[index];
padding: const EdgeInsets.fromLTRB(16, 8, 16, 88), return _buildChannelTile(context, connector, channel, index);
buildDefaultDragHandles: false, },
itemCount: channels.length, );
onReorder: (oldIndex, newIndex) { }(),
if (newIndex > oldIndex) newIndex -= 1;
final reordered = List<Channel>.from(channels);
final item = reordered.removeAt(oldIndex);
reordered.insert(newIndex, item);
unawaited(
connector.setChannelOrder(
reordered.map((c) => c.index).toList(),
),
);
},
itemBuilder: (context, index) {
final channel = channels[index];
return _buildChannelTile(context, connector, channel, index);
},
);
},
),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
onPressed: () => _showAddChannelDialog(context), onPressed: () => _showAddChannelDialog(context),
child: const Icon(Icons.add), child: const Icon(Icons.add),
@@ -144,7 +142,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
final unreadCount = connector.getUnreadCountForChannel(channel); final unreadCount = connector.getUnreadCountForChannel(channel);
return Card( return Card(
key: ValueKey('channel_${channel.index}'), key: ValueKey('channel_${channel.index}'),
margin: const EdgeInsets.symmetric(vertical: 4), margin: const EdgeInsets.only(bottom: 12),
child: ListTile( child: ListTile(
dense: true, dense: true,
minVerticalPadding: 0, minVerticalPadding: 0,
+94 -81
View File
@@ -32,7 +32,6 @@ class ChatScreen extends StatefulWidget {
class _ChatScreenState extends State<ChatScreen> { class _ChatScreenState extends State<ChatScreen> {
final _textController = TextEditingController(); final _textController = TextEditingController();
final _scrollController = ScrollController(); final _scrollController = ScrollController();
bool _clearPath = false;
@override @override
void initState() { void initState() {
@@ -40,6 +39,11 @@ class _ChatScreenState extends State<ChatScreen> {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
context.read<MeshCoreConnector>().setActiveContact(widget.contact.publicKeyHex); context.read<MeshCoreConnector>().setActiveContact(widget.contact.publicKeyHex);
// Scroll to bottom when opening chat
if (_scrollController.hasClients) {
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
}
}); });
} }
@@ -60,75 +64,86 @@ class _ChatScreenState extends State<ChatScreen> {
final contact = _resolveContact(connector); final contact = _resolveContact(connector);
final unreadCount = connector.getUnreadCountForContactKey(widget.contact.publicKeyHex); final unreadCount = connector.getUnreadCountForContactKey(widget.contact.publicKeyHex);
final unreadLabel = 'Unread: $unreadCount'; final unreadLabel = 'Unread: $unreadCount';
final pathLabel = _clearPath ? 'Flood (forced)' : _currentPathLabel(contact); final pathLabel = _currentPathLabel(contact);
final canShowPathDetails = !_clearPath && contact.path.isNotEmpty;
// Show path details if we have path data (from device or override)
final hasPathData = contact.path.isNotEmpty || contact.pathOverrideBytes != null;
final effectivePath = contact.pathOverrideBytes ?? contact.path;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text(contact.name), Text(contact.name),
if (canShowPathDetails) GestureDetector(
GestureDetector( behavior: HitTestBehavior.opaque,
behavior: HitTestBehavior.opaque, onTap: hasPathData ? () => _showFullPathDialog(context, effectivePath) : null,
onLongPress: () => _showFullPathDialog(context, contact.path), child: Text(
child: Text(
'$pathLabel$unreadLabel',
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.normal),
),
)
else
Text(
'$pathLabel$unreadLabel', '$pathLabel$unreadLabel',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.normal), style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.normal,
decoration: hasPathData ? TextDecoration.underline : null,
decorationStyle: TextDecorationStyle.dotted,
),
), ),
),
], ],
); );
}, },
), ),
centerTitle: false, centerTitle: false,
actions: [ actions: [
PopupMenuButton<String>( Consumer<MeshCoreConnector>(
icon: Icon(_clearPath ? Icons.waves : Icons.route), builder: (context, connector, _) {
tooltip: 'Routing mode', final contact = _resolveContact(connector);
onSelected: (mode) { final isFloodMode = contact.pathOverride == -1;
setState(() {
_clearPath = (mode == 'flood'); return PopupMenuButton<String>(
}); icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: 'Routing mode',
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(contact, pathLen: -1);
} else {
await connector.setPathOverride(contact, pathLen: null);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'auto',
child: Row(
children: [
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Auto (use saved path)',
style: TextStyle(
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: 'flood',
child: Row(
children: [
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Force Flood Mode',
style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
),
),
],
),
),
],
);
}, },
itemBuilder: (context) => [
PopupMenuItem(
value: 'auto',
child: Row(
children: [
Icon(Icons.auto_mode, size: 20, color: !_clearPath ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Auto (use saved path)',
style: TextStyle(
fontWeight: !_clearPath ? FontWeight.bold : FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: 'flood',
child: Row(
children: [
Icon(Icons.waves, size: 20, color: _clearPath ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Force Flood Mode',
style: TextStyle(
fontWeight: _clearPath ? FontWeight.bold : FontWeight.normal,
),
),
],
),
),
],
), ),
IconButton( IconButton(
icon: const Icon(Icons.timeline), icon: const Icon(Icons.timeline),
@@ -304,7 +319,6 @@ class _ChatScreenState extends State<ChatScreen> {
connector.sendMessage( connector.sendMessage(
widget.contact, widget.contact,
text, text,
clearPath: _clearPath,
); );
_textController.clear(); _textController.clear();
@@ -416,23 +430,14 @@ class _ChatScreenState extends State<ChatScreen> {
final pathBytes = Uint8List.fromList(path.pathBytes); final pathBytes = Uint8List.fromList(path.pathBytes);
final pathLength = path.pathBytes.length; final pathLength = path.pathBytes.length;
await connector.setContactPath( // Set the path override to persist user's choice
await connector.setPathOverride(
widget.contact, widget.contact,
pathBytes, pathLen: pathLength,
pathLength,
);
// Update contact in memory directly for immediate UI feedback
connector.updateContactInMemory(
widget.contact.publicKeyHex,
pathBytes: pathBytes, pathBytes: pathBytes,
pathLength: pathLength,
); );
if (!context.mounted) return; if (!context.mounted) return;
setState(() {
_clearPath = false;
});
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Using ${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'} path'), content: Text('Using ${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'} path'),
@@ -499,10 +504,9 @@ class _ChatScreenState extends State<ChatScreen> {
), ),
title: const Text('Force Flood Mode', style: TextStyle(fontSize: 14)), title: const Text('Force Flood Mode', style: TextStyle(fontSize: 14)),
subtitle: const Text('Use routing toggle in app bar', style: TextStyle(fontSize: 11)), subtitle: const Text('Use routing toggle in app bar', style: TextStyle(fontSize: 11)),
onTap: () { onTap: () async {
setState(() { await connector.setPathOverride(widget.contact, pathLen: -1);
_clearPath = true; if (!context.mounted) return;
});
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Flood mode enabled. Toggle back via routing icon in app bar.'), content: Text('Flood mode enabled. Toggle back via routing icon in app bar.'),
@@ -573,9 +577,16 @@ class _ChatScreenState extends State<ChatScreen> {
} }
String _currentPathLabel(Contact contact) { String _currentPathLabel(Contact contact) {
// Check if user has set a path override
if (contact.pathOverride != null) {
if (contact.pathOverride! < 0) return 'Flood (forced)';
if (contact.pathOverride == 0) return 'Direct (forced)';
return '${contact.pathOverride} hops (forced)';
}
// Use device's path
if (contact.pathLength < 0) return 'Flood (auto)'; if (contact.pathLength < 0) return 'Flood (auto)';
if (contact.pathLength == 0) return 'Direct'; if (contact.pathLength == 0) return 'Direct';
if (contact.pathIdList.isNotEmpty) return contact.pathIdList;
return '${contact.pathLength} hops'; return '${contact.pathLength} hops';
} }
@@ -604,7 +615,7 @@ class _ChatScreenState extends State<ChatScreen> {
'Location', 'Location',
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}', '${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
), ),
_buildInfoRow('Public Key', contact.publicKeyHex.substring(0, 16) + '...'), _buildInfoRow('Public Key', '${contact.publicKeyHex.substring(0, 16)}...'),
const Divider(), const Divider(),
SwitchListTile( SwitchListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
@@ -1125,12 +1136,10 @@ class _ChatScreenState extends State<ChatScreen> {
void _retryMessage(Message message) { void _retryMessage(Message message) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// Retry with clearPath if the message has no path or pathLength is -1 (indicating flood was used) // Retry using the contact's current path override setting
final shouldClearPath = message.pathLength != null && message.pathLength! < 0;
connector.sendMessage( connector.sendMessage(
widget.contact, widget.contact,
message.text, message.text,
clearPath: shouldClearPath,
); );
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Retrying message')), const SnackBar(content: Text('Retrying message')),
@@ -1151,7 +1160,11 @@ class _ChatScreenState extends State<ChatScreen> {
void _sendReaction(Message message, String emoji) { void _sendReaction(Message message, String emoji) {
final connector = context.read<MeshCoreConnector>(); final connector = context.read<MeshCoreConnector>();
final reactionText = 'r:${message.messageId}:$emoji'; // Send reaction with messageId if available, otherwise use lightweight format
// Parser will extract reactionKey (timestamp_senderPrefix) for deduplication
final messageId = message.messageId ??
'${message.timestamp.millisecondsSinceEpoch}_${message.senderKeyHex.substring(0, 8)}';
final reactionText = 'r:$messageId:$emoji';
connector.sendMessage(widget.contact, reactionText); connector.sendMessage(widget.contact, reactionText);
} }
} }
@@ -1176,7 +1189,6 @@ class _MessageBubble extends StatelessWidget {
final gifId = _parseGifId(message.text); final gifId = _parseGifId(message.text);
final poi = _parsePoiMessage(message.text); final poi = _parsePoiMessage(message.text);
final isFailed = message.status == MessageStatus.failed; final isFailed = message.status == MessageStatus.failed;
final attempts = message.retryCount + 1;
final bubbleColor = isFailed final bubbleColor = isFailed
? colorScheme.errorContainer ? colorScheme.errorContainer
: (isOutgoing ? colorScheme.primary : colorScheme.surfaceContainerHighest); : (isOutgoing ? colorScheme.primary : colorScheme.surfaceContainerHighest);
@@ -1240,13 +1252,14 @@ class _MessageBubble extends StatelessWidget {
color: textColor, color: textColor,
), ),
), ),
if (isOutgoing) ...[ if (isOutgoing && message.retryCount > 0) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'Attempts: $attempts', 'Retry ${message.retryCount}/4',
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: 10,
color: metaColor, color: metaColor,
fontWeight: FontWeight.w500,
), ),
), ),
], ],
+2 -2
View File
@@ -682,7 +682,7 @@ class _ContactsScreenState extends State<ContactsScreen>
return; return;
} }
final exists = _groups.any((g) { final exists = _groups.any((g) {
if (isEditing && g.name == group!.name) return false; if (isEditing && g.name == group.name) return false;
return g.name.toLowerCase() == name.toLowerCase(); return g.name.toLowerCase() == name.toLowerCase();
}); });
if (exists) { if (exists) {
@@ -693,7 +693,7 @@ class _ContactsScreenState extends State<ContactsScreen>
} }
setState(() { setState(() {
if (isEditing) { if (isEditing) {
final index = _groups.indexWhere((g) => g.name == group!.name); final index = _groups.indexWhere((g) => g.name == group.name);
if (index != -1) { if (index != -1) {
_groups[index] = ContactGroup( _groups[index] = ContactGroup(
name: name, name: name,
+1 -1
View File
@@ -123,7 +123,7 @@ class _DeviceScreenState extends State<DeviceScreen>
return Card( return Card(
elevation: 0, elevation: 0,
color: colorScheme.surfaceVariant, color: colorScheme.surfaceContainerHighest,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
), ),
+4 -3
View File
@@ -142,7 +142,9 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
), ),
); );
if (confirmed != true) return; if (confirmed != true || !mounted) return;
final cacheService = context.read<MapTileCacheService>();
setState(() { setState(() {
_isDownloading = true; _isDownloading = true;
@@ -150,7 +152,6 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
_failedTiles = 0; _failedTiles = 0;
}); });
final cacheService = context.read<MapTileCacheService>();
final result = await cacheService.downloadRegion( final result = await cacheService.downloadRegion(
bounds: bounds, bounds: bounds,
minZoom: _minZoom, minZoom: _minZoom,
@@ -198,7 +199,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
], ],
), ),
); );
if (confirmed != true) return; if (confirmed != true || !mounted) return;
final cacheService = context.read<MapTileCacheService>(); final cacheService = context.read<MapTileCacheService>();
await cacheService.clearCache(); await cacheService.clearCache();
+4 -1
View File
@@ -785,10 +785,13 @@ class _MapScreenState extends State<MapScreen> {
} }
final label = await _promptForLabel(context, defaultLabel); final label = await _promptForLabel(context, defaultLabel);
if (label == null) return; if (label == null || !mounted) return;
final markerText = _formatMarkerMessage(position, label, flags); final markerText = _formatMarkerMessage(position, label, flags);
if (!mounted) return;
await _showRecipientSheet( await _showRecipientSheet(
// ignore: use_build_context_synchronously
context: context, context: context,
connector: connector, connector: connector,
markerText: markerText, markerText: markerText,
+1 -1
View File
@@ -767,7 +767,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
return Card( return Card(
elevation: 0, elevation: 0,
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
color: colorScheme.surfaceVariant, color: colorScheme.surfaceContainerHighest,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
side: BorderSide(color: colorScheme.outlineVariant), side: BorderSide(color: colorScheme.outlineVariant),
+5 -4
View File
@@ -4,7 +4,6 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../models/radio_settings.dart'; import '../models/radio_settings.dart';
import '../services/app_settings_service.dart';
import 'app_settings_screen.dart'; import 'app_settings_screen.dart';
import 'ble_debug_log_screen.dart'; import 'ble_debug_log_screen.dart';
@@ -63,6 +62,8 @@ class SettingsScreen extends StatelessWidget {
_buildInfoRow('Node Name', connector.selfName!), _buildInfoRow('Node Name', connector.selfName!),
if (connector.selfPublicKey != null) if (connector.selfPublicKey != null)
_buildInfoRow('Public Key', '${pubKeyToHex(connector.selfPublicKey!).substring(0, 16)}...'), _buildInfoRow('Public Key', '${pubKeyToHex(connector.selfPublicKey!).substring(0, 16)}...'),
_buildInfoRow('Contacts Count', '${connector.contacts.length}'),
_buildInfoRow('Channel Count', '${connector.channels.length}'),
], ],
), ),
), ),
@@ -619,7 +620,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownButtonFormField<LoRaBandwidth>( DropdownButtonFormField<LoRaBandwidth>(
value: _bandwidth, initialValue: _bandwidth,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Bandwidth', labelText: 'Bandwidth',
border: OutlineInputBorder(), border: OutlineInputBorder(),
@@ -636,7 +637,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownButtonFormField<LoRaSpreadingFactor>( DropdownButtonFormField<LoRaSpreadingFactor>(
value: _spreadingFactor, initialValue: _spreadingFactor,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Spreading Factor', labelText: 'Spreading Factor',
border: OutlineInputBorder(), border: OutlineInputBorder(),
@@ -653,7 +654,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownButtonFormField<LoRaCodingRate>( DropdownButtonFormField<LoRaCodingRate>(
value: _codingRate, initialValue: _codingRate,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Coding Rate', labelText: 'Coding Rate',
border: OutlineInputBorder(), border: OutlineInputBorder(),
-152
View File
@@ -1,152 +0,0 @@
import 'dart:ffi';
import 'dart:io';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
const int _codec2Mode1300 = 4;
class Codec2Ffi {
Codec2Ffi._(this._lib)
: _codec2Create = _lib
.lookupFunction<_codec2_create_c, _codec2_create_d>('codec2_create'),
_codec2Destroy = _lib
.lookupFunction<_codec2_destroy_c, _codec2_destroy_d>('codec2_destroy'),
_codec2Encode = _lib
.lookupFunction<_codec2_encode_c, _codec2_encode_d>('codec2_encode'),
_codec2Decode = _lib
.lookupFunction<_codec2_decode_c, _codec2_decode_d>('codec2_decode'),
_codec2SamplesPerFrame = _lib.lookupFunction<_codec2_samples_per_frame_c,
_codec2_samples_per_frame_d>('codec2_samples_per_frame'),
_codec2BytesPerFrame = _lib.lookupFunction<_codec2_bytes_per_frame_c,
_codec2_bytes_per_frame_d>('codec2_bytes_per_frame');
static final Codec2Ffi instance = Codec2Ffi._(_openLibrary());
final DynamicLibrary _lib;
final _codec2_create_d _codec2Create;
final _codec2_destroy_d _codec2Destroy;
final _codec2_encode_d _codec2Encode;
final _codec2_decode_d _codec2Decode;
final _codec2_samples_per_frame_d _codec2SamplesPerFrame;
final _codec2_bytes_per_frame_d _codec2BytesPerFrame;
Codec2Session createSession() {
final handle = _codec2Create(_codec2Mode1300);
if (handle == nullptr) {
throw StateError('codec2_create returned null');
}
return Codec2Session._(
handle: handle,
destroy: _codec2Destroy,
encode: _codec2Encode,
decode: _codec2Decode,
samplesPerFrame: _codec2SamplesPerFrame,
bytesPerFrame: _codec2BytesPerFrame,
);
}
static DynamicLibrary _openLibrary() {
if (Platform.isAndroid) {
return DynamicLibrary.open('libcodec2.so');
}
if (Platform.isIOS || Platform.isMacOS) {
return DynamicLibrary.process();
}
throw UnsupportedError('Codec2 is only supported on Android and iOS.');
}
}
class Codec2Session {
Codec2Session._({
required this.handle,
required this.destroy,
required this.encode,
required this.decode,
required this.samplesPerFrame,
required this.bytesPerFrame,
});
final Pointer<Void> handle;
final _codec2_destroy_d destroy;
final _codec2_encode_d encode;
final _codec2_decode_d decode;
final _codec2_samples_per_frame_d samplesPerFrame;
final _codec2_bytes_per_frame_d bytesPerFrame;
int get samplesPerFrameValue => samplesPerFrame(handle);
int get bytesPerFrameValue => bytesPerFrame(handle);
Uint8List encodePcmFrame(Int16List pcmFrame) {
final bytesOut = calloc<Uint8>(bytesPerFrameValue);
final pcmIn = calloc<Int16>(samplesPerFrameValue);
try {
final sampleCount = samplesPerFrameValue;
final pcmBuffer = pcmIn.asTypedList(sampleCount);
final copyLen = pcmFrame.length < sampleCount ? pcmFrame.length : sampleCount;
pcmBuffer.setRange(0, copyLen, pcmFrame);
if (copyLen < sampleCount) {
for (var i = copyLen; i < sampleCount; i++) {
pcmBuffer[i] = 0;
}
}
encode(handle, bytesOut, pcmIn);
return Uint8List.fromList(bytesOut.asTypedList(bytesPerFrameValue));
} finally {
calloc.free(bytesOut);
calloc.free(pcmIn);
}
}
Int16List decodeCodecFrame(Uint8List codecFrame) {
final pcmOut = calloc<Int16>(samplesPerFrameValue);
final bytesIn = calloc<Uint8>(bytesPerFrameValue);
try {
final codecBuffer = bytesIn.asTypedList(bytesPerFrameValue);
codecBuffer.setRange(0, bytesPerFrameValue, codecFrame);
decode(handle, pcmOut, bytesIn);
return Int16List.fromList(pcmOut.asTypedList(samplesPerFrameValue));
} finally {
calloc.free(bytesIn);
calloc.free(pcmOut);
}
}
void dispose() {
destroy(handle);
}
}
typedef _codec2_create_c = Pointer<Void> Function(Int32 mode);
typedef _codec2_create_d = Pointer<Void> Function(int mode);
typedef _codec2_destroy_c = Void Function(Pointer<Void> codec2State);
typedef _codec2_destroy_d = void Function(Pointer<Void> codec2State);
typedef _codec2_encode_c = Void Function(
Pointer<Void> codec2State,
Pointer<Uint8> bytes,
Pointer<Int16> speechIn,
);
typedef _codec2_encode_d = void Function(
Pointer<Void> codec2State,
Pointer<Uint8> bytes,
Pointer<Int16> speechIn,
);
typedef _codec2_decode_c = Void Function(
Pointer<Void> codec2State,
Pointer<Int16> speechOut,
Pointer<Uint8> bytes,
);
typedef _codec2_decode_d = void Function(
Pointer<Void> codec2State,
Pointer<Int16> speechOut,
Pointer<Uint8> bytes,
);
typedef _codec2_samples_per_frame_c = Int32 Function(Pointer<Void> codec2State);
typedef _codec2_samples_per_frame_d = int Function(Pointer<Void> codec2State);
typedef _codec2_bytes_per_frame_c = Int32 Function(Pointer<Void> codec2State);
typedef _codec2_bytes_per_frame_d = int Function(Pointer<Void> codec2State);
+3 -3
View File
@@ -183,7 +183,7 @@ class MapTileCacheService {
int _lonToTileX(double lon, int zoom, int maxIndex) { int _lonToTileX(double lon, int zoom, int maxIndex) {
final n = 1 << zoom; final n = 1 << zoom;
final value = ((lon + 180.0) / 360.0 * n).floor(); final value = ((lon + 180.0) / 360.0 * n).floor();
return value.clamp(0, maxIndex) as int; return value.clamp(0, maxIndex);
} }
int _latToTileY(double lat, int zoom, int maxIndex) { int _latToTileY(double lat, int zoom, int maxIndex) {
@@ -194,12 +194,12 @@ class MapTileCacheService {
2 * 2 *
n) n)
.floor(); .floor();
return value.clamp(0, maxIndex) as int; return value.clamp(0, maxIndex);
} }
double _clampLatitude(double lat) { double _clampLatitude(double lat) {
const maxLat = 85.05112878; const maxLat = 85.05112878;
return lat.clamp(-maxLat, maxLat) as double; return lat.clamp(-maxLat, maxLat);
} }
String _buildTileUrl(int x, int y, int zoom) { String _buildTileUrl(int x, int y, int zoom) {
+194 -70
View File
@@ -19,6 +19,16 @@ class _AckHistoryEntry {
}); });
} }
class _AckHashMapping {
final String messageId;
final DateTime timestamp;
_AckHashMapping({
required this.messageId,
required this.timestamp,
});
}
class MessageRetryService extends ChangeNotifier { class MessageRetryService extends ChangeNotifier {
static const int maxRetries = 5; static const int maxRetries = 5;
static const int maxAckHistorySize = 100; static const int maxAckHistorySize = 100;
@@ -28,8 +38,10 @@ class MessageRetryService extends ChangeNotifier {
final Map<String, Message> _pendingMessages = {}; final Map<String, Message> _pendingMessages = {};
final Map<String, Contact> _pendingContacts = {}; final Map<String, Contact> _pendingContacts = {};
final Map<String, PathSelection> _pendingPathSelections = {}; final Map<String, PathSelection> _pendingPathSelections = {};
final Map<String, List<Uint8List>> _expectedAckHashes = {}; // Track all expected ACKs for retries final Map<String, _AckHashMapping> _ackHashToMessageId = {}; // ackHashHex messageId + timestamp for O(1) lookup
final Map<String, List<Uint8List>> _expectedAckHashes = {}; // Track all expected ACKs for retries (for history)
final List<_AckHistoryEntry> _ackHistory = []; // Rolling buffer of recent ACK hashes final List<_AckHistoryEntry> _ackHistory = []; // Rolling buffer of recent ACK hashes
final Map<String, List<String>> _pendingMessageQueuePerContact = {}; // contactPubKeyHex FIFO queue of messageIds
Function(Contact, String, int, int)? _sendMessageCallback; Function(Contact, String, int, int)? _sendMessageCallback;
Function(String, Message)? _addMessageCallback; Function(String, Message)? _addMessageCallback;
@@ -65,17 +77,16 @@ class MessageRetryService extends ChangeNotifier {
Future<void> sendMessageWithRetry({ Future<void> sendMessageWithRetry({
required Contact contact, required Contact contact,
required String text, required String text,
bool clearPath = false,
PathSelection? pathSelection, PathSelection? pathSelection,
Uint8List? pathBytes, Uint8List? pathBytes,
int? pathLength, int? pathLength,
}) async { }) async {
final messageId = const Uuid().v4(); final messageId = const Uuid().v4();
final useClearPath = clearPath || (pathSelection?.useFlood ?? false); final useFlood = pathSelection?.useFlood ?? false;
final messagePathBytes = final messagePathBytes =
pathBytes ?? _resolveMessagePathBytes(contact, useClearPath, pathSelection); pathBytes ?? _resolveMessagePathBytes(contact, useFlood, pathSelection);
final messagePathLength = final messagePathLength =
pathLength ?? _resolveMessagePathLength(contact, useClearPath, pathSelection); pathLength ?? _resolveMessagePathLength(contact, useFlood, pathSelection);
final message = Message( final message = Message(
senderKey: contact.publicKey, senderKey: contact.publicKey,
text: text, text: text,
@@ -126,6 +137,11 @@ class MessageRetryService extends ChangeNotifier {
final attempt = message.retryCount.clamp(0, 3); final attempt = message.retryCount.clamp(0, 3);
// Enqueue this message to track send order for ACK hash mapping (FIFO)
_pendingMessageQueuePerContact[contact.publicKeyHex] ??= [];
_pendingMessageQueuePerContact[contact.publicKeyHex]!.add(messageId);
debugPrint('Enqueued message $messageId for ${contact.name} (queue size: ${_pendingMessageQueuePerContact[contact.publicKeyHex]!.length})');
if (_sendMessageCallback != null) { if (_sendMessageCallback != null) {
final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000; final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
_sendMessageCallback!( _sendMessageCallback!(
@@ -138,58 +154,103 @@ class MessageRetryService extends ChangeNotifier {
} }
void updateMessageFromSent(Uint8List ackHash, int timeoutMs) { void updateMessageFromSent(Uint8List ackHash, int timeoutMs) {
for (var entry in _pendingMessages.entries) { final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
final message = entry.value;
// Only update if pending (waiting to send) or already sent with matching ACK
if (message.status == MessageStatus.pending ||
(message.status == MessageStatus.sent &&
message.expectedAckHash != null &&
listEquals(message.expectedAckHash, ackHash))) {
final contact = _pendingContacts[entry.key];
final selection = _pendingPathSelections[entry.key];
// Add this ACK hash to the list of expected ACKs for this message // Dequeue the next message from the FIFO queue to match with this RESP_CODE_SENT
_expectedAckHashes[entry.key] ??= []; // We iterate through contacts to find which one has a pending message in their queue
if (!_expectedAckHashes[entry.key]!.any((hash) => listEquals(hash, ackHash))) { String? messageId;
_expectedAckHashes[entry.key]!.add(Uint8List.fromList(ackHash)); Contact? contact;
debugPrint('Added ACK hash ${ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join()} to message ${entry.key} (total: ${_expectedAckHashes[entry.key]!.length})');
}
// Use device-provided timeout, or calculate from radio settings if timeout is 0 or invalid for (var entry in _pendingMessageQueuePerContact.entries) {
int actualTimeout = timeoutMs; final contactKey = entry.key;
if (timeoutMs <= 0 && _calculateTimeoutCallback != null && contact != null) { final queue = entry.value;
int pathLengthValue;
if (selection != null) { if (queue.isNotEmpty) {
pathLengthValue = selection.useFlood ? -1 : selection.hopCount; // Dequeue the first (oldest) message from this contact's queue
if (pathLengthValue < 0) pathLengthValue = contact.pathLength; final candidateMessageId = queue.removeAt(0);
} else if (message.pathLength != null) {
pathLengthValue = message.pathLength!; // Verify this message is still pending
} else { if (_pendingMessages.containsKey(candidateMessageId)) {
pathLengthValue = contact.pathLength; messageId = candidateMessageId;
contact = _pendingContacts[candidateMessageId];
debugPrint('Dequeued message $messageId for $contactKey (remaining in queue: ${queue.length})');
break;
} else {
debugPrint('Dequeued stale message $candidateMessageId - skipping');
// Continue to next message in queue
if (queue.isNotEmpty) {
final nextMessageId = queue.removeAt(0);
if (_pendingMessages.containsKey(nextMessageId)) {
messageId = nextMessageId;
contact = _pendingContacts[nextMessageId];
debugPrint('Dequeued next message $messageId for $contactKey (remaining: ${queue.length})');
break;
}
} }
actualTimeout = _calculateTimeoutCallback!(pathLengthValue, message.text.length);
debugPrint('Using calculated timeout: ${actualTimeout}ms for ${contact.pathLength} hops');
} }
final updatedMessage = message.copyWith(
status: MessageStatus.sent,
expectedAckHash: ackHash, // Keep the most recent one for display
estimatedTimeoutMs: actualTimeout,
sentAt: DateTime.now(),
);
_pendingMessages[entry.key] = updatedMessage;
if (_updateMessageCallback != null) {
_updateMessageCallback!(updatedMessage);
}
_startTimeoutTimer(entry.key, actualTimeout);
debugPrint('Updated message ${entry.key} with ACK hash: ${ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}');
return;
} }
} }
debugPrint('No pending message found for ACK hash: ${ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}');
if (messageId == null || contact == null) {
debugPrint('No pending message found for ACK hash: $ackHashHex (all queues empty or stale)');
return;
}
// Store the mapping for future lookups (e.g., when ACK arrives)
// Keep timestamp so we can clean up old mappings later
_ackHashToMessageId[ackHashHex] = _AckHashMapping(
messageId: messageId,
timestamp: DateTime.now(),
);
debugPrint('Mapped ACK hash $ackHashHex to message $messageId');
final message = _pendingMessages[messageId];
final selection = _pendingPathSelections[messageId];
if (message == null) {
debugPrint('Message $messageId no longer pending for ACK hash: $ackHashHex');
_ackHashToMessageId.remove(ackHashHex);
return;
}
// Add this ACK hash to the list of expected ACKs for this message (for history)
_expectedAckHashes[messageId] ??= [];
if (!_expectedAckHashes[messageId]!.any((hash) => listEquals(hash, ackHash))) {
_expectedAckHashes[messageId]!.add(Uint8List.fromList(ackHash));
debugPrint('Added ACK hash $ackHashHex to message $messageId (total: ${_expectedAckHashes[messageId]!.length})');
}
// Use device-provided timeout, or calculate from radio settings if timeout is 0 or invalid
int actualTimeout = timeoutMs;
if (timeoutMs <= 0 && _calculateTimeoutCallback != null) {
int pathLengthValue;
if (selection != null) {
pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
if (pathLengthValue < 0) pathLengthValue = contact.pathLength;
} else if (message.pathLength != null) {
pathLengthValue = message.pathLength!;
} else {
pathLengthValue = contact.pathLength;
}
actualTimeout = _calculateTimeoutCallback!(pathLengthValue, message.text.length);
debugPrint('Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue');
}
final updatedMessage = message.copyWith(
status: MessageStatus.sent,
expectedAckHash: ackHash,
estimatedTimeoutMs: actualTimeout,
sentAt: DateTime.now(),
);
_pendingMessages[messageId] = updatedMessage;
if (_updateMessageCallback != null) {
_updateMessageCallback!(updatedMessage);
}
_startTimeoutTimer(messageId, actualTimeout);
debugPrint('Updated message $messageId with ACK hash: $ackHashHex');
} }
void _startTimeoutTimer(String messageId, int timeoutMs) { void _startTimeoutTimer(String messageId, int timeoutMs) {
@@ -204,7 +265,10 @@ class MessageRetryService extends ChangeNotifier {
final contact = _pendingContacts[messageId]; final contact = _pendingContacts[messageId];
final selection = _pendingPathSelections[messageId]; final selection = _pendingPathSelections[messageId];
if (message == null || contact == null) return; if (message == null || contact == null) {
debugPrint('Timeout fired but message $messageId no longer pending (likely already delivered)');
return;
}
debugPrint('Timeout for message $messageId (retry ${message.retryCount}/${maxRetries - 1})'); debugPrint('Timeout for message $messageId (retry ${message.retryCount}/${maxRetries - 1})');
@@ -225,7 +289,12 @@ class MessageRetryService extends ChangeNotifier {
debugPrint('Scheduling retry after ${backoffMs}ms'); debugPrint('Scheduling retry after ${backoffMs}ms');
Timer(Duration(milliseconds: backoffMs), () { Timer(Duration(milliseconds: backoffMs), () {
_attemptSend(messageId); // Double-check message is still pending before retry
if (_pendingMessages.containsKey(messageId)) {
_attemptSend(messageId);
} else {
debugPrint('Retry cancelled: message $messageId was delivered while waiting');
}
}); });
} else { } else {
// Max retries reached - mark as failed // Max retries reached - mark as failed
@@ -240,6 +309,12 @@ class MessageRetryService extends ChangeNotifier {
_timeoutTimers[messageId]?.cancel(); _timeoutTimers[messageId]?.cancel();
_timeoutTimers.remove(messageId); _timeoutTimers.remove(messageId);
// Clean up the queue entry for this contact
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId);
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ?? false) {
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
}
// Check if we should clear the path on max retry // Check if we should clear the path on max retry
if (_appSettingsService?.settings.clearPathOnMaxRetry == true && if (_appSettingsService?.settings.clearPathOnMaxRetry == true &&
_clearContactPathCallback != null) { _clearContactPathCallback != null) {
@@ -291,28 +366,44 @@ class MessageRetryService extends ChangeNotifier {
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('ACK received: $ackHashHex, trip time: ${tripTimeMs}ms'); debugPrint('ACK received: $ackHashHex, trip time: ${tripTimeMs}ms');
debugPrint('Pending messages:');
for (var entry in _pendingMessages.entries) { // First, clean up old ACK hash mappings (older than 15 minutes)
final message = entry.value; final cutoffTime = DateTime.now().subtract(const Duration(minutes: 15));
final expectedHex = message.expectedAckHash?.map((b) => b.toRadixString(16).padLeft(2, '0')).join() ?? 'none'; final hashesToRemove = <String>[];
final allExpectedHashes = _expectedAckHashes[entry.key]?.map((h) => h.map((b) => b.toRadixString(16).padLeft(2, '0')).join()).join(', ') ?? 'none'; for (var entry in _ackHashToMessageId.entries) {
debugPrint(' ${entry.key}: status=${message.status}, latestAck=$expectedHex, allAcks=[$allExpectedHashes], retry=${message.retryCount}'); if (entry.value.timestamp.isBefore(cutoffTime)) {
hashesToRemove.add(entry.key);
}
}
for (var hash in hashesToRemove) {
_ackHashToMessageId.remove(hash);
}
if (hashesToRemove.isNotEmpty) {
debugPrint('Cleaned up ${hashesToRemove.length} old ACK hash mappings');
} }
// Check against ALL expected ACK hashes (from all retry attempts) // Use direct O(1) lookup via ACK hash mapping
for (var entry in _expectedAckHashes.entries) { final mapping = _ackHashToMessageId[ackHashHex];
final messageId = entry.key; if (mapping != null) {
final expectedHashes = entry.value; matchedMessageId = mapping.messageId;
debugPrint('Matched ACK to message via direct lookup: $matchedMessageId');
} else {
// Fallback: Check against ALL expected ACK hashes (from all retry attempts)
debugPrint('ACK not in mapping, checking _expectedAckHashes (${_expectedAckHashes.length} messages)');
for (var entry in _expectedAckHashes.entries) {
final messageId = entry.key;
final expectedHashes = entry.value;
for (final expectedHash in expectedHashes) { for (final expectedHash in expectedHashes) {
if (listEquals(expectedHash, ackHash)) { if (listEquals(expectedHash, ackHash)) {
matchedMessageId = messageId; matchedMessageId = messageId;
debugPrint('Matched ACK to message: $matchedMessageId (matched hash from attempt ${expectedHashes.indexOf(expectedHash)})'); debugPrint('Matched ACK to message via fallback: $matchedMessageId (attempt ${expectedHashes.indexOf(expectedHash)})');
break; break;
}
} }
}
if (matchedMessageId != null) break; if (matchedMessageId != null) break;
}
} }
if (matchedMessageId != null) { if (matchedMessageId != null) {
@@ -337,6 +428,14 @@ class MessageRetryService extends ChangeNotifier {
_pendingContacts.remove(matchedMessageId); _pendingContacts.remove(matchedMessageId);
_pendingPathSelections.remove(matchedMessageId); _pendingPathSelections.remove(matchedMessageId);
// Clean up the queue entry for this contact (remove any remaining references to this message)
if (contact != null) {
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(matchedMessageId);
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ?? false) {
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
}
}
if (_updateMessageCallback != null) { if (_updateMessageCallback != null) {
_updateMessageCallback!(deliveredMessage); _updateMessageCallback!(deliveredMessage);
} }
@@ -361,12 +460,25 @@ class MessageRetryService extends ChangeNotifier {
bool forceFlood, bool forceFlood,
PathSelection? selection, PathSelection? selection,
) { ) {
// Priority 1: Check user's path override
if (contact.pathOverride != null) {
if (contact.pathOverride! < 0) {
return Uint8List(0); // Force flood
}
return contact.pathOverrideBytes ?? Uint8List(0);
}
// Priority 2: Check forceFlood or device flood mode
if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) { if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) {
return Uint8List(0); return Uint8List(0);
} }
// Priority 3: Check PathSelection (auto-rotation)
if (selection != null && selection.pathBytes.isNotEmpty) { if (selection != null && selection.pathBytes.isNotEmpty) {
return Uint8List.fromList(selection.pathBytes); return Uint8List.fromList(selection.pathBytes);
} }
// Priority 4: Use device's discovered path
return contact.path; return contact.path;
} }
@@ -375,12 +487,22 @@ class MessageRetryService extends ChangeNotifier {
bool forceFlood, bool forceFlood,
PathSelection? selection, PathSelection? selection,
) { ) {
// Priority 1: Check user's path override
if (contact.pathOverride != null) {
return contact.pathOverride;
}
// Priority 2: Check forceFlood or device flood mode
if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) { if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) {
return -1; return -1;
} }
// Priority 3: Check PathSelection (auto-rotation)
if (selection != null && selection.pathBytes.isNotEmpty) { if (selection != null && selection.pathBytes.isNotEmpty) {
return selection.hopCount; return selection.hopCount;
} }
// Priority 4: Use device's discovered path
return contact.pathLength; return contact.pathLength;
} }
@@ -442,6 +564,8 @@ class MessageRetryService extends ChangeNotifier {
_pendingPathSelections.clear(); _pendingPathSelections.clear();
_expectedAckHashes.clear(); _expectedAckHashes.clear();
_ackHistory.clear(); _ackHistory.clear();
_ackHashToMessageId.clear();
_pendingMessageQueuePerContact.clear();
super.dispose(); super.dispose();
} }
} }
+12 -6
View File
@@ -67,27 +67,30 @@ class NotificationService {
required String contactName, required String contactName,
required String message, required String message,
String? contactId, String? contactId,
int? badgeCount,
}) async { }) async {
if (!_isInitialized) { if (!_isInitialized) {
await initialize(); await initialize();
} }
const androidDetails = AndroidNotificationDetails( final androidDetails = AndroidNotificationDetails(
'messages', 'messages',
'Messages', 'Messages',
channelDescription: 'New message notifications', channelDescription: 'New message notifications',
importance: Importance.high, importance: Importance.high,
priority: Priority.high, priority: Priority.high,
icon: '@mipmap/ic_launcher', icon: '@mipmap/ic_launcher',
number: badgeCount,
); );
const iosDetails = DarwinNotificationDetails( final iosDetails = DarwinNotificationDetails(
presentAlert: true, presentAlert: true,
presentBadge: true, presentBadge: true,
presentSound: true, presentSound: true,
badgeNumber: badgeCount,
); );
const notificationDetails = NotificationDetails( final notificationDetails = NotificationDetails(
android: androidDetails, android: androidDetails,
iOS: iosDetails, iOS: iosDetails,
); );
@@ -143,27 +146,30 @@ class NotificationService {
required String channelName, required String channelName,
required String message, required String message,
int? channelIndex, int? channelIndex,
int? badgeCount,
}) async { }) async {
if (!_isInitialized) { if (!_isInitialized) {
await initialize(); await initialize();
} }
const androidDetails = AndroidNotificationDetails( final androidDetails = AndroidNotificationDetails(
'channel_messages', 'channel_messages',
'Channel Messages', 'Channel Messages',
channelDescription: 'New channel message notifications', channelDescription: 'New channel message notifications',
importance: Importance.high, importance: Importance.high,
priority: Priority.high, priority: Priority.high,
icon: '@mipmap/ic_launcher', icon: '@mipmap/ic_launcher',
number: badgeCount,
); );
const iosDetails = DarwinNotificationDetails( final iosDetails = DarwinNotificationDetails(
presentAlert: true, presentAlert: true,
presentBadge: true, presentBadge: true,
presentSound: true, presentSound: true,
badgeNumber: badgeCount,
); );
const notificationDetails = NotificationDetails( final notificationDetails = NotificationDetails(
android: androidDetails, android: androidDetails,
iOS: iosDetails, iOS: iosDetails,
); );
+8
View File
@@ -33,6 +33,10 @@ class ContactStore {
'type': contact.type, 'type': contact.type,
'pathLength': contact.pathLength, 'pathLength': contact.pathLength,
'path': base64Encode(contact.path), 'path': base64Encode(contact.path),
'pathOverride': contact.pathOverride,
'pathOverrideBytes': contact.pathOverrideBytes != null
? base64Encode(contact.pathOverrideBytes!)
: null,
'latitude': contact.latitude, 'latitude': contact.latitude,
'longitude': contact.longitude, 'longitude': contact.longitude,
'lastSeen': contact.lastSeen.millisecondsSinceEpoch, 'lastSeen': contact.lastSeen.millisecondsSinceEpoch,
@@ -51,6 +55,10 @@ class ContactStore {
path: json['path'] != null path: json['path'] != null
? Uint8List.fromList(base64Decode(json['path'] as String)) ? Uint8List.fromList(base64Decode(json['path'] as String))
: Uint8List(0), : Uint8List(0),
pathOverride: json['pathOverride'] as int?,
pathOverrideBytes: json['pathOverrideBytes'] != null
? Uint8List.fromList(base64Decode(json['pathOverrideBytes'] as String))
: null,
latitude: (json['latitude'] as num?)?.toDouble(), latitude: (json['latitude'] as num?)?.toDouble(),
longitude: (json['longitude'] as num?)?.toDouble(), longitude: (json['longitude'] as num?)?.toDouble(),
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs), lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs),
+4 -4
View File
@@ -37,8 +37,8 @@ class QuickSwitchBar extends StatelessWidget {
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent, shadowColor: Colors.transparent,
indicatorColor: colorScheme.primaryContainer, indicatorColor: colorScheme.primaryContainer,
labelTextStyle: MaterialStateProperty.resolveWith((states) { labelTextStyle: WidgetStateProperty.resolveWith((states) {
final isSelected = states.contains(MaterialState.selected); final isSelected = states.contains(WidgetState.selected);
return labelStyle.copyWith( return labelStyle.copyWith(
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
color: isSelected color: isSelected
@@ -46,8 +46,8 @@ class QuickSwitchBar extends StatelessWidget {
: colorScheme.onSurfaceVariant, : colorScheme.onSurfaceVariant,
); );
}), }),
iconTheme: MaterialStateProperty.resolveWith((states) { iconTheme: WidgetStateProperty.resolveWith((states) {
final isSelected = states.contains(MaterialState.selected); final isSelected = states.contains(WidgetState.selected);
return IconThemeData( return IconThemeData(
color: isSelected color: isSelected
? colorScheme.onPrimaryContainer ? colorScheme.onPrimaryContainer
+9 -10
View File
@@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@@ -101,16 +100,16 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
} }
// If we got a response, login succeeded // If we got a response, login succeeded
if (mounted) { // Save password if requested
// Save password if requested if (_savePassword) {
if (_savePassword) { await _storage.saveRepeaterPassword(
await _storage.saveRepeaterPassword( widget.repeater.publicKeyHex, password);
widget.repeater.publicKeyHex, password); } else {
} else { // Remove saved password if user unchecked the box
// Remove saved password if user unchecked the box await _storage.removeRepeaterPassword(widget.repeater.publicKeyHex);
await _storage.removeRepeaterPassword(widget.repeater.publicKeyHex); }
}
if (mounted) {
Navigator.pop(context, password); Navigator.pop(context, password);
Future.microtask(() => widget.onLogin(password)); Future.microtask(() => widget.onLogin(password));
} }
@@ -7,14 +7,18 @@ import Foundation
import flutter_blue_plus_darwin import flutter_blue_plus_darwin
import flutter_local_notifications import flutter_local_notifications
import package_info_plus
import path_provider_foundation import path_provider_foundation
import shared_preferences_foundation import shared_preferences_foundation
import sqflite_darwin import sqflite_darwin
import wakelock_plus
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin")) FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
} }
+47 -7
View File
@@ -66,7 +66,7 @@ packages:
source: hosted source: hosted
version: "1.3.1" version: "1.3.1"
characters: characters:
dependency: transitive dependency: "direct main"
description: description:
name: characters name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
@@ -234,10 +234,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: flutter_blue_plus_winrt name: flutter_blue_plus_winrt
sha256: "5cfa5960ac8723771cbc59586588b100f38494390154b8a3268c95db37e21617" sha256: "0c87ca5bdf1a110d42847edeca8fbb11a9701738dc8526aefbb2a115bea29aef"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.0.7" version: "0.0.10"
flutter_cache_manager: flutter_cache_manager:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -436,10 +436,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.16.0" version: "1.17.0"
mgrs_dart: mgrs_dart:
dependency: transitive dependency: transitive
description: description:
@@ -464,6 +464,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" version: "2.1.0"
package_info_plus:
dependency: transitive
description:
name: package_info_plus
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
url: "https://pub.dev"
source: hosted
version: "9.0.0"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
path: path:
dependency: transitive dependency: transitive
description: description:
@@ -745,10 +761,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.6" version: "0.7.7"
timezone: timezone:
dependency: transitive dependency: transitive
description: description:
@@ -797,6 +813,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.0.2" version: "15.0.2"
wakelock_plus:
dependency: "direct main"
description:
name: wakelock_plus
sha256: "9296d40c9adbedaba95d1e704f4e0b434be446e2792948d0e4aa977048104228"
url: "https://pub.dev"
source: hosted
version: "1.4.0"
wakelock_plus_platform_interface:
dependency: transitive
description:
name: wakelock_plus_platform_interface
sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
web: web:
dependency: transitive dependency: transitive
description: description:
@@ -805,6 +837,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "5.15.0"
wkt_parser: wkt_parser:
dependency: transitive dependency: transitive
description: description:
+2
View File
@@ -47,6 +47,8 @@ dependencies:
cached_network_image: ^3.4.1 cached_network_image: ^3.4.1
flutter_cache_manager: ^3.4.1 flutter_cache_manager: ^3.4.1
flutter_foreground_task: ^6.1.2 flutter_foreground_task: ^6.1.2
wakelock_plus: ^1.2.8
characters: ^1.4.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: