Add retransmission handling

This commit is contained in:
Janez T
2026-03-06 20:12:12 +01:00
parent 7cd5351921
commit 85d0b26c67
14 changed files with 1417 additions and 999 deletions

View File

@@ -249,7 +249,10 @@ class ConnectionProvider with ChangeNotifier {
await Future.delayed(const Duration(milliseconds: 300)); await Future.delayed(const Duration(milliseconds: 300));
if (pendingOp.messageId != null) { if (pendingOp.messageId != null) {
_messageDeliveryTracker.trackPendingMessage(pendingOp.messageId!); _messageDeliveryTracker.trackPendingDirectMessage(
pendingOp.messageId!,
pendingOp.contactPublicKey,
);
} }
await _activeService.sendTextMessage( await _activeService.sendTextMessage(
@@ -352,7 +355,11 @@ class ConnectionProvider with ChangeNotifier {
service.onMessageSent = service.onMessageSent =
(expectedAckTag, suggestedTimeoutMs, isFloodMode, contactPublicKey) { (expectedAckTag, suggestedTimeoutMs, isFloodMode, contactPublicKey) {
final messageId = _messageDeliveryTracker.popPendingMessageId(); final messageId = contactPublicKey != null
? _messageDeliveryTracker.popPendingDirectMessageId(
contactPublicKey,
)
: _messageDeliveryTracker.popPendingMessageId();
if (messageId != null) { if (messageId != null) {
_messageDeliveryTracker.mapAckTagToMessageId( _messageDeliveryTracker.mapAckTagToMessageId(
expectedAckTag, expectedAckTag,
@@ -1148,7 +1155,10 @@ class ConnectionProvider with ChangeNotifier {
// The MessagesProvider now uses simple ACK tag → recipientPublicKey mapping. // The MessagesProvider now uses simple ACK tag → recipientPublicKey mapping.
// We still track here for the SENT response callback to work. // We still track here for the SENT response callback to work.
if (messageId != null) { if (messageId != null) {
_messageDeliveryTracker.trackPendingMessage(messageId); _messageDeliveryTracker.trackPendingDirectMessage(
messageId,
contactPublicKey,
);
debugPrint(' 📝 Tracked pending message: $messageId'); debugPrint(' 📝 Tracked pending message: $messageId');
} }

View File

@@ -1,3 +1,5 @@
import 'dart:typed_data';
/// Message delivery tracking helper /// Message delivery tracking helper
/// ///
/// Manages message delivery tracking for sent messages, including: /// Manages message delivery tracking for sent messages, including:
@@ -16,6 +18,9 @@ class MessageDeliveryTracker {
/// Messages tracked here before sending, popped when RESP_CODE_SENT arrives /// Messages tracked here before sending, popped when RESP_CODE_SENT arrives
final List<String> _pendingMessageIds = []; final List<String> _pendingMessageIds = [];
/// Contact-scoped FIFOs for matching direct-message SENT responses.
final Map<String, List<String>> _pendingMessageIdsByContact = {};
/// Map of ACK tag to message ID for delivery confirmation /// Map of ACK tag to message ID for delivery confirmation
final Map<int, String> _ackTagToMessageId = {}; final Map<int, String> _ackTagToMessageId = {};
@@ -33,6 +38,15 @@ class MessageDeliveryTracker {
_pendingMessageIds.add(messageId); _pendingMessageIds.add(messageId);
} }
/// Track a pending direct message ID for a specific contact.
void trackPendingDirectMessage(String messageId, Uint8List contactPublicKey) {
trackPendingMessage(messageId);
final contactKey = _contactKey(contactPublicKey);
_pendingMessageIdsByContact
.putIfAbsent(contactKey, () => [])
.add(messageId);
}
/// Pop the next pending message ID from FIFO queue /// Pop the next pending message ID from FIFO queue
/// ///
/// Called when RESP_CODE_SENT arrives. Returns null if queue empty. /// Called when RESP_CODE_SENT arrives. Returns null if queue empty.
@@ -43,6 +57,24 @@ class MessageDeliveryTracker {
return _pendingMessageIds.removeAt(0); return _pendingMessageIds.removeAt(0);
} }
/// Pop the next pending direct message ID for a specific contact.
///
/// Falls back to the legacy global FIFO if the contact queue is empty.
String? popPendingDirectMessageId(Uint8List contactPublicKey) {
final contactKey = _contactKey(contactPublicKey);
final queue = _pendingMessageIdsByContact[contactKey];
if (queue == null || queue.isEmpty) {
return popPendingMessageId();
}
final messageId = queue.removeAt(0);
if (queue.isEmpty) {
_pendingMessageIdsByContact.remove(contactKey);
}
_pendingMessageIds.remove(messageId);
return messageId;
}
/// Map ACK tag to message ID after RESP_CODE_SENT received /// Map ACK tag to message ID after RESP_CODE_SENT received
/// ///
/// Creates bidirectional mapping for efficient cleanup and tracking. /// Creates bidirectional mapping for efficient cleanup and tracking.
@@ -86,6 +118,17 @@ class MessageDeliveryTracker {
_ackTagToMessageId.remove(ackTag); _ackTagToMessageId.remove(ackTag);
_ackTagTimestamps.remove(ackTag); _ackTagTimestamps.remove(ackTag);
} }
_pendingMessageIds.remove(messageId);
final emptyKeys = <String>[];
for (final entry in _pendingMessageIdsByContact.entries) {
entry.value.remove(messageId);
if (entry.value.isEmpty) {
emptyKeys.add(entry.key);
}
}
for (final key in emptyKeys) {
_pendingMessageIdsByContact.remove(key);
}
} }
/// Clean up stale ACK mappings /// Clean up stale ACK mappings
@@ -114,6 +157,7 @@ class MessageDeliveryTracker {
/// Clear all tracking state /// Clear all tracking state
void clearTracking() { void clearTracking() {
_pendingMessageIds.clear(); _pendingMessageIds.clear();
_pendingMessageIdsByContact.clear();
_ackTagToMessageId.clear(); _ackTagToMessageId.clear();
_messageIdToAckTag.clear(); _messageIdToAckTag.clear();
_ackTagTimestamps.clear(); _ackTagTimestamps.clear();
@@ -133,9 +177,7 @@ class MessageDeliveryTracker {
/// Get oldest pending ACK timestamp (for debugging) /// Get oldest pending ACK timestamp (for debugging)
DateTime? get oldestPendingTimestamp { DateTime? get oldestPendingTimestamp {
if (_ackTagTimestamps.isEmpty) return null; if (_ackTagTimestamps.isEmpty) return null;
return _ackTagTimestamps.values.reduce( return _ackTagTimestamps.values.reduce((a, b) => a.isBefore(b) ? a : b);
(a, b) => a.isBefore(b) ? a : b,
);
} }
/// Get diagnostic info for debugging /// Get diagnostic info for debugging
@@ -145,6 +187,15 @@ class MessageDeliveryTracker {
'shouldRateLimit': shouldRateLimit, 'shouldRateLimit': shouldRateLimit,
'oldestPending': oldestPendingTimestamp?.toIso8601String(), 'oldestPending': oldestPendingTimestamp?.toIso8601String(),
'ackTags': _ackTagToMessageId.keys.toList(), 'ackTags': _ackTagToMessageId.keys.toList(),
'pendingByContact': _pendingMessageIdsByContact.map(
(key, value) => MapEntry(key, value.length),
),
}; };
} }
String _contactKey(Uint8List contactPublicKey) {
return contactPublicKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
}
} }

View File

@@ -28,6 +28,9 @@ class MessagesProvider with ChangeNotifier {
// Key: message ID (not ACK tag, since multiple messages can share same ACK) // Key: message ID (not ACK tag, since multiple messages can share same ACK)
final Map<String, Timer> _timeoutTimers = {}; final Map<String, Timer> _timeoutTimers = {};
// Recently completed ACKs are kept briefly to ignore duplicate confirms.
final Map<int, DateTime> _completedAckHistory = {};
// Retry management // Retry management
final MessageRetryManager _retryManager = MessageRetryManager(); final MessageRetryManager _retryManager = MessageRetryManager();
@@ -906,11 +909,13 @@ class MessagesProvider with ChangeNotifier {
final (groupId, recipientPublicKey) = groupMapping; final (groupId, recipientPublicKey) = groupMapping;
debugPrint(' ✅ This is part of a grouped message: $groupId'); debugPrint(' ✅ This is part of a grouped message: $groupId');
// Update the recipient status to "sent" in the grouped message // ACK-tracked recipients stay pending until the delivery confirm arrives.
updateGroupedMessageRecipientStatus( updateGroupedMessageRecipientStatus(
groupId, groupId,
recipientPublicKey, recipientPublicKey,
MessageDeliveryStatus.sent, expectedAckTag > 0
? MessageDeliveryStatus.sending
: MessageDeliveryStatus.sent,
); );
// Track the ACK for this specific recipient // Track the ACK for this specific recipient
@@ -1022,7 +1027,9 @@ class MessagesProvider with ChangeNotifier {
); );
final updatedMessage = message.copyWith( final updatedMessage = message.copyWith(
deliveryStatus: MessageDeliveryStatus.sent, deliveryStatus: expectedAckTag > 0
? MessageDeliveryStatus.sending
: MessageDeliveryStatus.sent,
expectedAckTag: expectedAckTag > 0 ? expectedAckTag : null, expectedAckTag: expectedAckTag > 0 ? expectedAckTag : null,
suggestedTimeoutMs: suggestedTimeoutMs > 0 ? suggestedTimeoutMs : null, suggestedTimeoutMs: suggestedTimeoutMs > 0 ? suggestedTimeoutMs : null,
); );
@@ -1245,6 +1252,7 @@ class MessagesProvider with ChangeNotifier {
/// Update message status to delivered with RTT /// Update message status to delivered with RTT
void markMessageDelivered(int ackCode, int roundTripTimeMs) { void markMessageDelivered(int ackCode, int roundTripTimeMs) {
_cleanupCompletedAckHistory();
debugPrint( debugPrint(
'🔍 [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms', '🔍 [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms',
); );
@@ -1296,6 +1304,7 @@ class MessagesProvider with ChangeNotifier {
); );
_ackTagToRecipients.remove(ackCode); _ackTagToRecipients.remove(ackCode);
_pendingSentMessages.remove(ackCode); _pendingSentMessages.remove(ackCode);
_rememberCompletedAck(ackCode);
} }
debugPrint( debugPrint(
@@ -1339,6 +1348,7 @@ class MessagesProvider with ChangeNotifier {
// Remove from pending // Remove from pending
_pendingSentMessages.remove(ackCode); _pendingSentMessages.remove(ackCode);
_rememberCompletedAck(ackCode);
// Clear retry tracking on successful delivery // Clear retry tracking on successful delivery
_retryManager.clearRetry(message.id); _retryManager.clearRetry(message.id);
@@ -1362,6 +1372,12 @@ class MessagesProvider with ChangeNotifier {
); );
} }
} else { } else {
if (_completedAckHistory.containsKey(ackCode)) {
debugPrint(
' [MessagesProvider] Duplicate/late ACK $ackCode ignored (already completed)',
);
return;
}
debugPrint( debugPrint(
'⚠️ [MessagesProvider] No pending message found for ACK code: $ackCode', '⚠️ [MessagesProvider] No pending message found for ACK code: $ackCode',
); );
@@ -1482,18 +1498,31 @@ class MessagesProvider with ChangeNotifier {
debugPrint( debugPrint(
'⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId', '⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId',
); );
final currentIndex = _messages.indexWhere((m) => m.id == messageId);
if (currentIndex == -1) {
return;
}
final currentMessage = _messages[currentIndex];
if (currentMessage.deliveryStatus == MessageDeliveryStatus.delivered) {
return;
}
if (sendMessageCallback != null) { if (sendMessageCallback != null) {
await sendMessageCallback!( final queued = await sendMessageCallback!(
contactPublicKey: contact.publicKey, contactPublicKey: contact.publicKey,
text: message.text, text: message.text,
messageId: messageId, messageId: messageId,
contact: contact, contact: contact,
retryAttempt: nextAttempt, retryAttempt: nextAttempt,
); );
if (!queued) {
_markAsPermanentlyFailed(messageId, currentMessage);
}
} else { } else {
debugPrint( debugPrint(
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot retry', '⚠️ [MessagesProvider] sendMessageCallback not set, cannot retry',
); );
_markAsPermanentlyFailed(messageId, currentMessage);
} }
}); });
@@ -1529,17 +1558,21 @@ class MessagesProvider with ChangeNotifier {
// Send with flood mode (no retry after this) // Send with flood mode (no retry after this)
if (sendMessageCallback != null) { if (sendMessageCallback != null) {
await sendMessageCallback!( final queued = await sendMessageCallback!(
contactPublicKey: contact.publicKey, contactPublicKey: contact.publicKey,
text: message.text, text: message.text,
messageId: messageId, messageId: messageId,
contact: contact, contact: contact,
retryAttempt: 0, // Reset attempt for flood retryAttempt: 0, // Reset attempt for flood
); );
if (!queued) {
_markAsPermanentlyFailed(messageId, _messages[index]);
}
} else { } else {
debugPrint( debugPrint(
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot send flood', '⚠️ [MessagesProvider] sendMessageCallback not set, cannot send flood',
); );
_markAsPermanentlyFailed(messageId, _messages[index]);
} }
_persistMessages(); _persistMessages();
@@ -1608,17 +1641,21 @@ class MessagesProvider with ChangeNotifier {
// Send again // Send again
if (sendMessageCallback != null) { if (sendMessageCallback != null) {
await sendMessageCallback!( final queued = await sendMessageCallback!(
contactPublicKey: contact.publicKey, contactPublicKey: contact.publicKey,
text: message.text, text: message.text,
messageId: messageId, messageId: messageId,
contact: contact, contact: contact,
retryAttempt: 0, retryAttempt: 0,
); );
if (!queued) {
_markAsPermanentlyFailed(messageId, _messages[index]);
}
} else { } else {
debugPrint( debugPrint(
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot resend', '⚠️ [MessagesProvider] sendMessageCallback not set, cannot resend',
); );
_markAsPermanentlyFailed(messageId, _messages[index]);
} }
_persistMessages(); _persistMessages();
@@ -1631,10 +1668,29 @@ class MessagesProvider with ChangeNotifier {
timer.cancel(); timer.cancel();
} }
_timeoutTimers.clear(); _timeoutTimers.clear();
_completedAckHistory.clear();
// Clear retry manager // Clear retry manager
_retryManager.clearAll(); _retryManager.clearAll();
super.dispose(); super.dispose();
} }
void _rememberCompletedAck(int ackCode) {
_completedAckHistory[ackCode] = DateTime.now();
_cleanupCompletedAckHistory();
}
void _cleanupCompletedAckHistory({
Duration maxAge = const Duration(minutes: 15),
}) {
final cutoff = DateTime.now().subtract(maxAge);
final staleAcks = _completedAckHistory.entries
.where((entry) => entry.value.isBefore(cutoff))
.map((entry) => entry.key)
.toList();
for (final ack in staleAcks) {
_completedAckHistory.remove(ack);
}
}
} }

View File

@@ -46,9 +46,9 @@ class HomeScreen extends StatefulWidget {
State<HomeScreen> createState() => _HomeScreenState(); State<HomeScreen> createState() => _HomeScreenState();
} }
class _HomeScreenState extends State<HomeScreen> class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
with SingleTickerProviderStateMixin {
late TabController _tabController; late TabController _tabController;
late final AppProvider _appProvider;
int _currentIndex = 0; int _currentIndex = 0;
bool _isMapFullscreen = false; bool _isMapFullscreen = false;
bool _showRxTxIndicators = true; bool _showRxTxIndicators = true;
@@ -74,9 +74,13 @@ class _HomeScreenState extends State<HomeScreen>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_appProvider = context.read<AppProvider>();
_isMapEnabled = _appProvider.isMapEnabled;
_isContactsEnabled = _appProvider.isContactsEnabled;
_appProvider.addListener(_handleAppProviderChanged);
// Initialize synchronously so first build always has a valid controller. // Initialize synchronously so first build always has a valid controller.
_initTabController(); _initTabController();
_loadTabVisibilityAndInitTabs();
_loadRxTxPreference(); _loadRxTxPreference();
// Show permission dialog after the first frame if needed // Show permission dialog after the first frame if needed
@@ -87,19 +91,6 @@ class _HomeScreenState extends State<HomeScreen>
} }
} }
Future<void> _loadTabVisibilityAndInitTabs() async {
final prefs = await SharedPreferences.getInstance();
final mapEnabled = prefs.getBool('map_enabled') ?? true;
final contactsEnabled = prefs.getBool('contacts_enabled') ?? true;
if (!mounted) return;
if (_isMapEnabled != mapEnabled || _isContactsEnabled != contactsEnabled) {
_updateTabController(
mapEnabled: mapEnabled,
contactsEnabled: contactsEnabled,
);
}
}
void _initTabController() { void _initTabController() {
_tabController = TabController(length: _enabledTabs.length, vsync: this); _tabController = TabController(length: _enabledTabs.length, vsync: this);
_tabController.addListener(_onTabChanged); _tabController.addListener(_onTabChanged);
@@ -110,6 +101,14 @@ class _HomeScreenState extends State<HomeScreen>
}); });
} }
void _handleAppProviderChanged() {
if (!mounted) return;
_updateTabController(
mapEnabled: _appProvider.isMapEnabled,
contactsEnabled: _appProvider.isContactsEnabled,
);
}
void _onTabChanged() { void _onTabChanged() {
final previousTab = _currentTab; final previousTab = _currentTab;
final nextIndex = _tabController.index; final nextIndex = _tabController.index;
@@ -136,15 +135,21 @@ class _HomeScreenState extends State<HomeScreen>
} }
final oldTabs = _enabledTabs; final oldTabs = _enabledTabs;
final oldIndex = _tabController.index; final oldIndex = oldTabs.isEmpty
? 0
: _tabController.index.clamp(0, oldTabs.length - 1);
final oldTab = oldTabs[oldIndex]; final oldTab = oldTabs[oldIndex];
final oldController = _tabController; final oldController = _tabController;
oldController.removeListener(_onTabChanged); oldController.removeListener(_onTabChanged);
oldController.dispose();
// Update state // Update state
_isMapEnabled = mapEnabled; _isMapEnabled = mapEnabled;
_isContactsEnabled = contactsEnabled; _isContactsEnabled = contactsEnabled;
if (!_isMapEnabled) {
_isMapFullscreen = false;
}
final newTabs = _enabledTabs; final newTabs = _enabledTabs;
final newIndex = newTabs.indexOf(oldTab); final newIndex = newTabs.indexOf(oldTab);
@@ -157,11 +162,7 @@ class _HomeScreenState extends State<HomeScreen>
_tabController.index = _currentIndex; _tabController.index = _currentIndex;
setState(() {}); setState(() {});
_handleTabActivated(_currentTab);
// Dispose old controller after widgets have rebound to the new controller.
WidgetsBinding.instance.addPostFrameCallback((_) {
oldController.dispose();
});
} }
void _navigateToTab(_HomeTab tab) { void _navigateToTab(_HomeTab tab) {
@@ -199,6 +200,7 @@ class _HomeScreenState extends State<HomeScreen>
@override @override
void dispose() { void dispose() {
_appProvider.removeListener(_handleAppProviderChanged);
_tabController.removeListener(_onTabChanged); _tabController.removeListener(_onTabChanged);
_tabController.dispose(); _tabController.dispose();
super.dispose(); super.dispose();
@@ -343,18 +345,7 @@ class _HomeScreenState extends State<HomeScreen>
messagesProvider.setLocalizations(localizations); messagesProvider.setLocalizations(localizations);
} }
// Check if tab visibility settings changed and update tab controller context.watch<AppProvider>();
final appProvider = context.watch<AppProvider>();
if (_isMapEnabled != appProvider.isMapEnabled ||
_isContactsEnabled != appProvider.isContactsEnabled) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_updateTabController(
mapEnabled: appProvider.isMapEnabled,
contactsEnabled: appProvider.isContactsEnabled,
);
});
}
final enabledTabs = _enabledTabs; final enabledTabs = _enabledTabs;
final isMapTabActive = _currentTab == _HomeTab.map; final isMapTabActive = _currentTab == _HomeTab.map;

View File

@@ -1349,9 +1349,6 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
// SAR marker data is automatically added by SarMessageParser.enhanceMessage in MessagesProvider // SAR marker data is automatically added by SarMessageParser.enhanceMessage in MessagesProvider
); );
// Add to messages list with "sending" status
messagesProvider.addSentMessage(sentMessage);
// Look up the room contact for path logging // Look up the room contact for path logging
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
final roomContact = contactsProvider.contacts.where((c) { final roomContact = contactsProvider.contacts.where((c) {
@@ -1359,6 +1356,9 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
c.publicKey.matches(roomPublicKey); c.publicKey.matches(roomPublicKey);
}).firstOrNull; }).firstOrNull;
// Add to messages list with "sending" status
messagesProvider.addSentMessage(sentMessage, contact: roomContact);
// Send SAR message to selected room (persisted and immutable) // Send SAR message to selected room (persisted and immutable)
final sentSuccessfully = await connectionProvider.sendTextMessage( final sentSuccessfully = await connectionProvider.sendTextMessage(
contactPublicKey: roomPublicKey!, contactPublicKey: roomPublicKey!,

View File

@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/foundation.dart' show kIsWeb;
import 'dart:math' as math; import 'dart:math' as math;
@@ -44,11 +45,13 @@ class MessagesTab extends StatefulWidget {
} }
class _MessagesTabState extends State<MessagesTab> { class _MessagesTabState extends State<MessagesTab> {
static const int _maxContactMessageBytes = 156;
static const int _maxChannelMessageBytes = 127;
final TextEditingController _textController = TextEditingController(); final TextEditingController _textController = TextEditingController();
final FocusNode _focusNode = FocusNode(); final FocusNode _focusNode = FocusNode();
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
int _characterCount = 0; int _messageByteCount = 0;
static const int _maxCharacters = 160;
String? _highlightedMessageId; String? _highlightedMessageId;
Timer? _highlightTimer; // Timer for clearing message highlight Timer? _highlightTimer; // Timer for clearing message highlight
@@ -166,10 +169,44 @@ class _MessagesTabState extends State<MessagesTab> {
void _updateCharacterCount() { void _updateCharacterCount() {
setState(() { setState(() {
_characterCount = _textController.text.length; _messageByteCount = utf8.encode(_textController.text).length;
}); });
} }
int get _maxMessageBytes =>
_destinationType == MessageDestinationPreferences.destinationTypeChannel
? _maxChannelMessageBytes
: _maxContactMessageBytes;
TextInputFormatter get _messageByteLimiter =>
TextInputFormatter.withFunction((oldValue, newValue) {
if (utf8.encode(newValue.text).length <= _maxMessageBytes) {
return newValue;
}
return oldValue;
});
void _enforceMessageByteLimit() {
final currentText = _textController.text;
if (utf8.encode(currentText).length <= _maxMessageBytes) {
_updateCharacterCount();
return;
}
var truncated = currentText;
while (truncated.isNotEmpty &&
utf8.encode(truncated).length > _maxMessageBytes) {
truncated = truncated.substring(0, truncated.length - 1);
}
_textController.value = _textController.value.copyWith(
text: truncated,
selection: TextSelection.collapsed(offset: truncated.length),
composing: TextRange.empty,
);
_updateCharacterCount();
}
/// Load saved message destination from preferences /// Load saved message destination from preferences
Future<void> _loadSavedDestination() async { Future<void> _loadSavedDestination() async {
final savedDestination = final savedDestination =
@@ -211,6 +248,8 @@ class _MessagesTabState extends State<MessagesTab> {
await MessageDestinationPreferences.clearDestination(); await MessageDestinationPreferences.clearDestination();
} }
} }
_enforceMessageByteLimit();
} }
/// Show recipient selector bottom sheet /// Show recipient selector bottom sheet
@@ -250,6 +289,8 @@ class _MessagesTabState extends State<MessagesTab> {
_selectedRecipient = recipient; _selectedRecipient = recipient;
}); });
_enforceMessageByteLimit();
// Save to preferences // Save to preferences
await MessageDestinationPreferences.setDestination( await MessageDestinationPreferences.setDestination(
type, type,
@@ -383,7 +424,7 @@ class _MessagesTabState extends State<MessagesTab> {
); );
// Add to messages list with "sending" status // Add to messages list with "sending" status
messagesProvider.addSentMessage(sentMessage); messagesProvider.addSentMessage(sentMessage, contact: _selectedRecipient);
// Send to selected channel // Send to selected channel
await connectionProvider.sendChannelMessage( await connectionProvider.sendChannelMessage(
@@ -616,7 +657,7 @@ class _MessagesTabState extends State<MessagesTab> {
deliveryStatus: MessageDeliveryStatus.sending, deliveryStatus: MessageDeliveryStatus.sending,
recipientPublicKey: isChannel ? null : recipient?.publicKey, recipientPublicKey: isChannel ? null : recipient?.publicKey,
); );
messagesProvider.addSentMessage(placeholder); messagesProvider.addSentMessage(placeholder, contact: recipient);
// Send IE1 envelope via normal message path. // Send IE1 envelope via normal message path.
final envelopeText = envelope.encode(); final envelopeText = envelope.encode();
@@ -921,7 +962,7 @@ class _MessagesTabState extends State<MessagesTab> {
channelIdx: channelIdx, channelIdx: channelIdx,
recipientPublicKey: isChannel ? null : recipient?.publicKey, recipientPublicKey: isChannel ? null : recipient?.publicKey,
); );
messagesProvider.addSentMessage(sentMsg); messagesProvider.addSentMessage(sentMsg, contact: recipient);
try { try {
if (isChannel) { if (isChannel) {
@@ -1327,9 +1368,6 @@ class _MessagesTabState extends State<MessagesTab> {
// SAR marker data is automatically added by SarMessageParser.enhanceMessage in MessagesProvider // SAR marker data is automatically added by SarMessageParser.enhanceMessage in MessagesProvider
); );
// Add to messages list with "sending" status
messagesProvider.addSentMessage(sentMessage);
// Look up the room contact for path logging // Look up the room contact for path logging
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
final roomContact = contactsProvider.contacts.where((c) { final roomContact = contactsProvider.contacts.where((c) {
@@ -1337,6 +1375,9 @@ class _MessagesTabState extends State<MessagesTab> {
c.publicKey.matches(roomPublicKey); c.publicKey.matches(roomPublicKey);
}).firstOrNull; }).firstOrNull;
// Add to messages list with "sending" status
messagesProvider.addSentMessage(sentMessage, contact: roomContact);
// Send SAR message to selected room (persisted and immutable) // Send SAR message to selected room (persisted and immutable)
final sentSuccessfully = await connectionProvider.sendTextMessage( final sentSuccessfully = await connectionProvider.sendTextMessage(
contactPublicKey: roomPublicKey!, contactPublicKey: roomPublicKey!,
@@ -1563,12 +1604,6 @@ class _MessagesTabState extends State<MessagesTab> {
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
border: Border(
top: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
),
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -1576,7 +1611,28 @@ class _MessagesTabState extends State<MessagesTab> {
SafeArea( SafeArea(
top: false, top: false,
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 12), padding: const EdgeInsets.fromLTRB(10, 10, 10, 10),
child: Container(
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: Theme.of(
context,
).dividerColor.withValues(alpha: 0.35),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 18,
offset: const Offset(0, 6),
),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 10, 8),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -1584,18 +1640,23 @@ class _MessagesTabState extends State<MessagesTab> {
Row( Row(
children: [ children: [
Container( Container(
width: 46, width: 42,
height: 46, height: 42,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context) color: Theme.of(
.colorScheme context,
.primaryContainer ).colorScheme.surface,
.withValues(alpha: 0.95),
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all(
color: Theme.of(
context,
).dividerColor.withValues(alpha: 0.35),
),
), ),
child: IconButton( child: IconButton(
icon: Icon( icon: Icon(
_isRecording ? Icons.stop : Icons.add, _isRecording ? Icons.stop : Icons.add,
size: 22,
), ),
tooltip: _isRecording tooltip: _isRecording
? 'Stop recording' ? 'Stop recording'
@@ -1607,34 +1668,29 @@ class _MessagesTabState extends State<MessagesTab> {
? Colors.red ? Colors.red
: Theme.of( : Theme.of(
context, context,
).colorScheme.onPrimaryContainer, ).colorScheme.primary,
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 8),
Expanded( Expanded(
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(20),
onTap: _showRecipientSelector, onTap: _showRecipientSelector,
child: Ink( child: Ink(
height: 46, height: 42,
decoration: BoxDecoration( decoration: BoxDecoration(
color:
_destinationType ==
MessageDestinationPreferences
.destinationTypeChannel
? Theme.of(context)
.colorScheme
.surfaceContainerHighest
: Theme.of(
context,
).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(22),
border: Border.all(
color: Theme.of( color: Theme.of(
context, context,
).dividerColor.withValues(alpha: 0.7), ).colorScheme.surface,
borderRadius: BorderRadius.circular(
20,
),
border: Border.all(
color: Theme.of(context)
.dividerColor
.withValues(alpha: 0.35),
), ),
), ),
child: Padding( child: Padding(
@@ -1645,35 +1701,33 @@ class _MessagesTabState extends State<MessagesTab> {
children: [ children: [
Icon( Icon(
_getDestinationIcon(), _getDestinationIcon(),
size: 18, size: 17,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Text( child: Text(
_getDestinationLabel(), _getDestinationLabel(),
overflow: TextOverflow.ellipsis, overflow:
TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w600, fontWeight:
color: FontWeight.w600,
_destinationType == color: Theme.of(
MessageDestinationPreferences
.destinationTypeChannel
? Theme.of(
context, context,
).colorScheme.onSurface ).colorScheme.onSurface,
: Theme.of(context)
.colorScheme
.onSecondaryContainer,
), ),
), ),
), ),
Icon( Icon(
Icons.expand_more_rounded, Icons.expand_more_rounded,
size: 20, size: 20,
color: Theme.of( color: Theme.of(context)
context, .colorScheme
).colorScheme.onSurfaceVariant, .onSurfaceVariant,
), ),
], ],
), ),
@@ -1684,35 +1738,49 @@ class _MessagesTabState extends State<MessagesTab> {
), ),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 8),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Expanded( Expanded(
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 180), duration: const Duration(
milliseconds: 180,
),
constraints: const BoxConstraints( constraints: const BoxConstraints(
minHeight: 48, minHeight: 46,
maxHeight: 140, maxHeight: 132,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of( color: Theme.of(
context, context,
).colorScheme.surfaceContainerHigh, ).colorScheme.surface,
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(24),
border: Border.all( border: Border.all(
color: _focusNode.hasFocus color: _focusNode.hasFocus
? Theme.of( ? Theme.of(
context, context,
).colorScheme.primary ).colorScheme.primary
: Theme.of(context).dividerColor : Theme.of(context).dividerColor
.withValues(alpha: 0.6), .withValues(alpha: 0.35),
width: _focusNode.hasFocus ? 1.5 : 1, width: _focusNode.hasFocus ? 1.4 : 1,
), ),
boxShadow: _focusNode.hasFocus
? [
BoxShadow(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.10),
blurRadius: 12,
offset: const Offset(0, 4),
),
]
: null,
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 18, horizontal: 16,
vertical: 12, vertical: 12,
), ),
child: TextField( child: TextField(
@@ -1722,9 +1790,7 @@ class _MessagesTabState extends State<MessagesTab> {
maxLines: 4, maxLines: 4,
keyboardType: TextInputType.multiline, keyboardType: TextInputType.multiline,
inputFormatters: [ inputFormatters: [
LengthLimitingTextInputFormatter( _messageByteLimiter,
_maxCharacters,
),
], ],
style: const TextStyle(fontSize: 15), style: const TextStyle(fontSize: 15),
textAlignVertical: textAlignVertical:
@@ -1735,25 +1801,31 @@ class _MessagesTabState extends State<MessagesTab> {
)!.typeYourMessage, )!.typeYourMessage,
hintStyle: TextStyle( hintStyle: TextStyle(
fontSize: 15, fontSize: 15,
color: Theme.of( color: Theme.of(context)
context, .colorScheme
).colorScheme.onSurfaceVariant, .onSurfaceVariant
.withValues(alpha: 0.9),
), ),
filled: false,
fillColor: Colors.transparent,
border: InputBorder.none, border: InputBorder.none,
isCollapsed: true, isCollapsed: true,
), ),
textInputAction: TextInputAction.newline, textInputAction:
TextInputAction.newline,
), ),
), ),
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 8),
Builder( Builder(
builder: (context) { builder: (context) {
final canSendText = final canSendText =
!_isRecording && !_isRecording &&
!_isSendingVoice && !_isSendingVoice &&
_textController.text.trim().isNotEmpty; _textController.text
.trim()
.isNotEmpty;
final semanticsLabel = _isRecording final semanticsLabel = _isRecording
? 'Recording... release to send voice' ? 'Recording... release to send voice'
: (_isSendingVoice : (_isSendingVoice
@@ -1766,11 +1838,15 @@ class _MessagesTabState extends State<MessagesTab> {
button: true, button: true,
enabled: enabled:
canSendText || canSendText ||
(_voiceSupported && !_isSendingVoice), (_voiceSupported &&
!_isSendingVoice),
label: semanticsLabel, label: semanticsLabel,
onTap: canSendText ? _sendMessage : null, onTap: canSendText
? _sendMessage
: null,
onLongPress: onLongPress:
(_voiceSupported && !_isSendingVoice) (_voiceSupported &&
!_isSendingVoice)
? () { ? () {
if (_isRecording) { if (_isRecording) {
_stopAndSendVoice(); _stopAndSendVoice();
@@ -1790,11 +1866,13 @@ class _MessagesTabState extends State<MessagesTab> {
? (_) => _startVoiceRecording() ? (_) => _startVoiceRecording()
: null, : null,
onLongPressEnd: onLongPressEnd:
(_voiceSupported && _isRecording) (_voiceSupported &&
_isRecording)
? (_) => _stopAndSendVoice() ? (_) => _stopAndSendVoice()
: null, : null,
onLongPressCancel: onLongPressCancel:
(_voiceSupported && _isRecording) (_voiceSupported &&
_isRecording)
? () => _stopAndSendVoice() ? () => _stopAndSendVoice()
: null, : null,
child: Column( child: Column(
@@ -1804,31 +1882,48 @@ class _MessagesTabState extends State<MessagesTab> {
duration: const Duration( duration: const Duration(
milliseconds: 180, milliseconds: 180,
), ),
width: 48, width: 46,
height: 48, height: 46,
decoration: BoxDecoration( decoration: BoxDecoration(
color: color:
canSendText || _isRecording canSendText ||
_isRecording
? Theme.of( ? Theme.of(
context, context,
).colorScheme.primary ).colorScheme.primary
: Theme.of(context) : Theme.of(
.colorScheme context,
.surfaceContainerHighest, ).colorScheme.surface,
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all(
color:
canSendText ||
_isRecording
? Colors.transparent
: Theme.of(context)
.dividerColor
.withValues(
alpha: 0.35,
),
),
boxShadow: boxShadow:
canSendText || _isRecording canSendText ||
_isRecording
? [ ? [
BoxShadow( BoxShadow(
color: color:
Theme.of(context) Theme.of(
context,
)
.colorScheme .colorScheme
.primary .primary
.withValues( .withValues(
alpha: 0.28, alpha:
0.22,
), ),
blurRadius: 16, blurRadius: 14,
offset: const Offset( offset:
const Offset(
0, 0,
6, 6,
), ),
@@ -1838,8 +1933,7 @@ class _MessagesTabState extends State<MessagesTab> {
), ),
child: _isSendingVoice child: _isSendingVoice
? Center( ? Center(
child: child: CircularProgressIndicator(
CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
color: color:
Theme.of( Theme.of(
@@ -1851,32 +1945,43 @@ class _MessagesTabState extends State<MessagesTab> {
) )
: Icon( : Icon(
_isRecording _isRecording
? Icons.mic_rounded ? Icons
: Icons.send_rounded, .mic_rounded
: Icons
.send_rounded,
size: 22,
color: color:
canSendText || canSendText ||
_isRecording _isRecording
? Theme.of(context) ? Theme.of(
context,
)
.colorScheme .colorScheme
.onPrimary .onPrimary
: Theme.of(context) : Theme.of(
context,
)
.colorScheme .colorScheme
.onSurfaceVariant, .onSurfaceVariant,
), ),
), ),
const SizedBox(height: 6), const SizedBox(height: 4),
Text( Text(
'$_characterCount/$_maxCharacters', '$_messageByteCount/$_maxMessageBytes',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 11,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w500,
color: color:
_characterCount > _messageByteCount >
_maxCharacters * 0.9 _maxMessageBytes *
0.9
? Colors.orange.shade800 ? Colors.orange.shade800
: Theme.of(context) : Theme.of(context)
.colorScheme .colorScheme
.onSurfaceVariant, .onSurfaceVariant
.withValues(
alpha: 0.9,
),
), ),
), ),
], ],
@@ -1892,6 +1997,8 @@ class _MessagesTabState extends State<MessagesTab> {
), ),
), ),
), ),
),
),
], ],
), ),
), ),

View File

@@ -24,6 +24,9 @@ extension MessageLocalization on Message {
switch (deliveryStatus) { switch (deliveryStatus) {
case MessageDeliveryStatus.sending: case MessageDeliveryStatus.sending:
if (isContactMessage) {
return l10n.pending;
}
return l10n.sending; return l10n.sending;
case MessageDeliveryStatus.sent: case MessageDeliveryStatus.sent:
return l10n.sent; return l10n.sent;

View File

@@ -849,6 +849,7 @@ class DrawingToolbar extends StatelessWidget {
contactPublicKey: room.publicKey, contactPublicKey: room.publicKey,
text: message, text: message,
messageId: messageId, messageId: messageId,
contact: room,
); );
debugPrint(' ✅ Sent successfully'); debugPrint(' ✅ Sent successfully');

View File

@@ -118,7 +118,26 @@ class _MessageBubbleState extends State<MessageBubble> {
); );
// Add retry message to provider // Add retry message to provider
messagesProvider.addSentMessage(retryMessage); Contact? roomContact;
if (failedMessage.messageType == MessageType.contact) {
if (failedMessage.recipientPublicKey == null) {
messagesProvider.markMessageFailed(retryMessageId);
ToastLogger.error(
context,
AppLocalizations.of(context)!.cannotRetryMissingRecipient,
);
return;
}
final contactsProvider = context.read<ContactsProvider>();
roomContact = contactsProvider.contacts.where((c) {
return c.publicKey.length >=
failedMessage.recipientPublicKey!.length &&
c.publicKey.matches(failedMessage.recipientPublicKey!);
}).firstOrNull;
}
messagesProvider.addSentMessage(retryMessage, contact: roomContact);
// Resend the message // Resend the message
if (failedMessage.messageType == MessageType.contact) { if (failedMessage.messageType == MessageType.contact) {
@@ -132,14 +151,6 @@ class _MessageBubbleState extends State<MessageBubble> {
return; return;
} }
// Look up the room contact for path logging
final contactsProvider = context.read<ContactsProvider>();
final roomContact = contactsProvider.contacts.where((c) {
return c.publicKey.length >=
failedMessage.recipientPublicKey!.length &&
c.publicKey.matches(failedMessage.recipientPublicKey!);
}).firstOrNull;
// Resend to the same room // Resend to the same room
final sentSuccessfully = await connectionProvider.sendTextMessage( final sentSuccessfully = await connectionProvider.sendTextMessage(
contactPublicKey: failedMessage.recipientPublicKey!, contactPublicKey: failedMessage.recipientPublicKey!,
@@ -1805,7 +1816,7 @@ class _MessageBubbleState extends State<MessageBubble> {
? '${l10n.channel}: $channelDisplayName' ? '${l10n.channel}: $channelDisplayName'
: null; : null;
final shouldFloatBubble = message.isChannelMessage || widget.isCompact; final shouldFloatBubble = widget.isCompact;
final bubble = ConstrainedBox( final bubble = ConstrainedBox(
constraints: BoxConstraints( constraints: BoxConstraints(
maxWidth: shouldFloatBubble maxWidth: shouldFloatBubble
@@ -1946,7 +1957,11 @@ class _MessageBubbleState extends State<MessageBubble> {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.draw, size: 16, color: Colors.white), const Icon(
Icons.draw,
size: 16,
color: Colors.white,
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
AppLocalizations.of(context)!.mapDrawing, AppLocalizations.of(context)!.mapDrawing,
@@ -2075,7 +2090,9 @@ class _MessageBubbleState extends State<MessageBubble> {
), ),
const SizedBox(width: 2), const SizedBox(width: 2),
Text( Text(
message.pathLen == 0 ? 'direct' : '${message.pathLen}hop', message.pathLen == 0
? 'direct'
: '${message.pathLen}hop',
style: Theme.of(context).textTheme.labelSmall?.copyWith( style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of( color: Theme.of(
context, context,
@@ -2101,7 +2118,8 @@ class _MessageBubbleState extends State<MessageBubble> {
Row( Row(
children: [ children: [
Text( Text(
message.sarCustomEmoji ?? message.sarMarkerType!.emoji, message.sarCustomEmoji ??
message.sarMarkerType!.emoji,
style: const TextStyle(fontSize: 32), style: const TextStyle(fontSize: 32),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
@@ -2129,9 +2147,8 @@ class _MessageBubbleState extends State<MessageBubble> {
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
'${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}', '${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}',
style: Theme.of(context).textTheme.labelMedium?.copyWith( style: Theme.of(context).textTheme.labelMedium
fontFamily: 'monospace', ?.copyWith(fontFamily: 'monospace'),
),
), ),
], ],
], ],
@@ -2201,7 +2218,9 @@ class _MessageBubbleState extends State<MessageBubble> {
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
colorName, colorName,
style: Theme.of(context).textTheme.labelSmall, style: Theme.of(
context,
).textTheme.labelSmall,
), ),
], ],
), ),
@@ -2229,10 +2248,16 @@ class _MessageBubbleState extends State<MessageBubble> {
// Tic-Tac-Toe control message content // Tic-Tac-Toe control message content
else if (ticTacToeEvent?.type == TicTacToeEventType.start && else if (ticTacToeEvent?.type == TicTacToeEventType.start &&
!widget.isCompact) !widget.isCompact)
TicTacToeMessageBubble(message: message, isSentByMe: isOwnMessage) TicTacToeMessageBubble(
message: message,
isSentByMe: isOwnMessage,
)
// Regular message content // Regular message content
else if (!message.isDrawing || widget.isCompact) else if (!message.isDrawing || widget.isCompact)
Text(message.text, style: Theme.of(context).textTheme.bodyMedium), Text(
message.text,
style: Theme.of(context).textTheme.bodyMedium,
),
if (!widget.isCompact && if (!widget.isCompact &&
!isSarMarker && !isSarMarker &&
@@ -2319,7 +2344,9 @@ class _MessageBubbleState extends State<MessageBubble> {
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Icon( Icon(
_isExpanded ? Icons.expand_less : Icons.expand_more, _isExpanded
? Icons.expand_less
: Icons.expand_more,
size: 14, size: 14,
color: Theme.of( color: Theme.of(
context, context,
@@ -2343,7 +2370,9 @@ class _MessageBubbleState extends State<MessageBubble> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
AppLocalizations.of(context)!.recipientDetails, AppLocalizations.of(
context,
)!.recipientDetails,
style: Theme.of(context).textTheme.labelSmall style: Theme.of(context).textTheme.labelSmall
?.copyWith(fontWeight: FontWeight.bold), ?.copyWith(fontWeight: FontWeight.bold),
), ),
@@ -2469,7 +2498,10 @@ class _MessageBubbleState extends State<MessageBubble> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.2), color: Colors.orange.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.orange, width: 1), border: Border.all(
color: Colors.orange,
width: 1,
),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,

View File

@@ -97,9 +97,7 @@ class TicTacToeMessageBubble extends StatelessWidget {
children: [ children: [
Text( Text(
'Tic-Tac-Toe · Game ${state.gameId}', 'Tic-Tac-Toe · Game ${state.gameId}',
style: Theme.of( style: Theme.of(context).textTheme.labelMedium?.copyWith(
context,
).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: titleColor, color: titleColor,
), ),
@@ -167,7 +165,7 @@ class TicTacToeMessageBubble extends StatelessWidget {
deliveryStatus: MessageDeliveryStatus.sending, deliveryStatus: MessageDeliveryStatus.sending,
recipientPublicKey: opponent.publicKey, recipientPublicKey: opponent.publicKey,
); );
messagesProvider.addSentMessage(sentMessage); messagesProvider.addSentMessage(sentMessage, contact: opponent);
final sent = await connectionProvider.sendTextMessage( final sent = await connectionProvider.sendTextMessage(
contactPublicKey: opponent.publicKey, contactPublicKey: opponent.publicKey,

View File

@@ -235,7 +235,7 @@ packages:
source: hosted source: hosted
version: "3.3.0" version: "3.3.0"
fake_async: fake_async:
dependency: transitive dependency: "direct dev"
description: description:
name: fake_async name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"

View File

@@ -144,6 +144,7 @@ dev_dependencies:
# rules and activating additional ones. # rules and activating additional ones.
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
flutter_launcher_icons: "^0.14.4" flutter_launcher_icons: "^0.14.4"
fake_async: ^1.3.3
dependency_overrides: dependency_overrides:
meshcore_client: meshcore_client:

View File

@@ -0,0 +1,37 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/providers/helpers/message_delivery_tracker.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('MessageDeliveryTracker', () {
test('matches pending direct messages by contact', () {
final tracker = MessageDeliveryTracker();
final alice = Uint8List.fromList(List<int>.filled(32, 0xAA));
final bob = Uint8List.fromList(List<int>.filled(32, 0xBB));
tracker.trackPendingDirectMessage('alice-1', alice);
tracker.trackPendingDirectMessage('bob-1', bob);
tracker.trackPendingDirectMessage('alice-2', alice);
expect(tracker.popPendingDirectMessageId(alice), 'alice-1');
expect(tracker.popPendingDirectMessageId(bob), 'bob-1');
expect(tracker.popPendingDirectMessageId(alice), 'alice-2');
});
test('removeByMessageId clears pending queue state', () {
final tracker = MessageDeliveryTracker();
final alice = Uint8List.fromList(List<int>.filled(32, 0xAA));
tracker.trackPendingDirectMessage('alice-1', alice);
tracker.mapAckTagToMessageId(42, 'alice-1');
tracker.removeByMessageId('alice-1');
expect(tracker.getMessageIdForAck(42), isNull);
expect(tracker.popPendingDirectMessageId(alice), isNull);
});
});
}

View File

@@ -0,0 +1,131 @@
import 'dart:typed_data';
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart';
Contact _buildContact() {
return Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)),
type: ContactType.chat,
flags: 0,
outPathLen: 1,
outPath: Uint8List.fromList([1, 2, 3, 4]),
advName: 'Teammate',
lastAdvert: 1700000000,
advLat: 0,
advLon: 0,
lastMod: 1700000000,
);
}
Message _buildDirectMessage(String id) {
return Message(
id: id,
messageType: MessageType.contact,
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'hello',
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.sending,
recipientPublicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)),
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('MessagesProvider retransmission', () {
test('direct messages stay pending until delivery ACK arrives', () {
final provider = MessagesProvider();
provider.addSentMessage(
_buildDirectMessage('m1'),
contact: _buildContact(),
);
provider.markMessageSent('m1', 77, 250);
expect(
provider.messages.single.deliveryStatus,
MessageDeliveryStatus.sending,
);
expect(provider.messages.single.expectedAckTag, 77);
provider.markMessageDelivered(77, 180);
expect(
provider.messages.single.deliveryStatus,
MessageDeliveryStatus.delivered,
);
expect(provider.messages.single.roundTripTimeMs, 180);
});
test('channel messages are marked sent immediately', () {
final provider = MessagesProvider();
provider.addSentMessage(
Message(
id: 'c1',
messageType: MessageType.channel,
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'broadcast',
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.sending,
),
);
provider.markMessageSent('c1', 0, 0);
expect(
provider.messages.single.deliveryStatus,
MessageDeliveryStatus.sent,
);
});
test('missing ACK schedules a delayed retransmission', () {
fakeAsync((async) {
final provider = MessagesProvider();
var retryCalls = 0;
provider.sendMessageCallback =
({
required contactPublicKey,
required text,
required messageId,
required contact,
retryAttempt = 0,
}) async {
retryCalls += 1;
return true;
};
provider.addSentMessage(
_buildDirectMessage('m2'),
contact: _buildContact(),
);
provider.markMessageSent('m2', 88, 10);
async.elapse(const Duration(milliseconds: 11));
async.flushMicrotasks();
expect(provider.messages.single.retryAttempt, 1);
expect(
provider.messages.single.deliveryStatus,
MessageDeliveryStatus.sending,
);
expect(retryCalls, 0);
async.elapse(const Duration(seconds: 4));
async.flushMicrotasks();
expect(retryCalls, 1);
});
});
});
}