From 2482f94cd04880db5057236778cf50b4cc5a3239 Mon Sep 17 00:00:00 2001 From: Janez T Date: Thu, 5 Mar 2026 20:58:29 +0100 Subject: [PATCH] Push meshcore_client and refresh pub --- lib/providers/app_provider.dart | 149 +++++--- .../helpers/fragment_ack_wait_registry.dart | 43 +++ .../helpers/session_metadata_restore.dart | 38 ++ lib/providers/image_provider.dart | 26 ++ lib/providers/voice_provider.dart | 27 ++ lib/screens/messages_tab.dart | 356 +++++++++++------- lib/utils/image_message_parser.dart | 24 +- .../messages/image_message_bubble.dart | 60 ++- lib/widgets/messages/message_bubble.dart | 235 +++++++----- .../messages/voice_message_bubble.dart | 28 +- pubspec.lock | 2 +- .../fragment_ack_wait_registry_test.dart | 37 ++ .../session_metadata_restore_test.dart | 86 +++++ .../providers/image_provider_cancel_test.dart | 52 +++ test/utils/image_message_parser_test.dart | 13 + 15 files changed, 885 insertions(+), 291 deletions(-) create mode 100644 lib/providers/helpers/fragment_ack_wait_registry.dart create mode 100644 lib/providers/helpers/session_metadata_restore.dart create mode 100644 test/providers/helpers/fragment_ack_wait_registry_test.dart create mode 100644 test/providers/helpers/session_metadata_restore_test.dart create mode 100644 test/providers/image_provider_cancel_test.dart diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 4d296fd..672edbc 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -8,6 +8,8 @@ import 'drawing_provider.dart'; import 'channels_provider.dart'; import 'voice_provider.dart'; import 'image_provider.dart' as ip; +import 'helpers/fragment_ack_wait_registry.dart'; +import 'helpers/session_metadata_restore.dart'; import '../services/tile_cache_service.dart'; import '../services/location_tracking_service.dart'; import '../services/packet_capture_storage_service.dart'; @@ -62,8 +64,10 @@ class AppProvider with ChangeNotifier { final Map _imageSessionSenderKey6 = {}; final Map _voiceMissingRetryTimers = {}; final Map _voiceMissingRetryAttempts = {}; - final Map> _voiceFragmentAckWaiters = {}; - final Map> _imageFragmentAckWaiters = {}; + final FragmentAckWaitRegistry _voiceFragmentAckWaiters = + FragmentAckWaitRegistry(); + final FragmentAckWaitRegistry _imageFragmentAckWaiters = + FragmentAckWaitRegistry(); Timer? _packetCaptureFlushTimer; String? _lastPersistedPacketSignature; bool _isPersistingPacketCapture = false; @@ -165,12 +169,35 @@ class AppProvider with ChangeNotifier { // Give DrawingProvider a moment to finish loading too await Future.delayed(const Duration(milliseconds: 100)); + _restoreSessionMetadataFromMessages(); + debugPrint( '🔄 [AppProvider] Early sync: syncing drawings from messages...', ); messagesProvider.syncDrawingsWithProvider(drawingProvider); } + void _restoreSessionMetadataFromMessages() { + final restored = restoreSessionMetadataFromMessages( + messagesProvider.messages.map((message) => message.text), + ); + + _voiceSessionSenderKey6.addAll(restored.voiceSenderKeyBySession); + for (final entry in restored.imageEnvelopeBySession.entries) { + _imageSessionSenderKey6[entry.key] = entry.value.senderKey6.toLowerCase(); + imageProvider.registerEnvelope(entry.value); + } + + final restoredVoice = restored.voiceSenderKeyBySession.length; + final restoredImage = restored.imageEnvelopeBySession.length; + if (restoredVoice > 0 || restoredImage > 0) { + debugPrint( + '🔄 [AppProvider] Restored session metadata from messages: ' + '$restoredVoice voice, $restoredImage image', + ); + } + } + /// Load simple mode setting from shared preferences Future _loadSimpleMode() async { try { @@ -788,12 +815,12 @@ class AppProvider with ChangeNotifier { final imageFetchRequest = ImageFetchRequest.tryParseBinary(payload); if (imageFetchRequest != null) { - final requester = contactsProvider.findContactByPrefixHex( - imageFetchRequest.requesterKey6, - ); + final requester = _resolveImageFetchRequester(imageFetchRequest); if (requester == null) { debugPrint( - 'âš ī¸ [AppProvider] Image fetch requester contact not found (binary)', + 'âš ī¸ [AppProvider] Image fetch requester contact not found (binary) ' + 'for session ${imageFetchRequest.sessionId} / ' + '${imageFetchRequest.requesterKey6}', ); messagesProvider.logSystemMessage( text: @@ -804,7 +831,9 @@ class AppProvider with ChangeNotifier { } if (requester.outPathLen > _maxDirectPayloadHops) { debugPrint( - 'âš ī¸ [AppProvider] Image fetch requester too far: ${requester.outPathLen} hops', + 'âš ī¸ [AppProvider] Image fetch requester too far: ' + '${requester.outPathLen} hops for session ' + '${imageFetchRequest.sessionId}', ); messagesProvider.logSystemMessage( text: @@ -813,6 +842,10 @@ class AppProvider with ChangeNotifier { ); return; } + debugPrint( + '📷 [AppProvider] Serving image session ${imageFetchRequest.sessionId} ' + 'to ${requester.advName} via ${requester.outPathLen} hop(s)', + ); unawaited( imageProvider.serveSessionTo( sessionId: imageFetchRequest.sessionId, @@ -1189,6 +1222,42 @@ class AppProvider with ChangeNotifier { return contactsProvider.findContactByPrefixHex(prefixHex.toLowerCase()); } + Contact? _resolveImageFetchRequester(ImageFetchRequest request) { + final liveContact = _resolveContactByPrefixHex(request.requesterKey6); + if (liveContact != null) { + return liveContact; + } + + for (final message in messagesProvider.messages.reversed) { + final envelope = ImageEnvelope.tryParse(message.text); + if (envelope == null || envelope.sessionId != request.sessionId) { + continue; + } + final recipientKey = message.recipientPublicKey; + if (recipientKey == null || recipientKey.isEmpty) { + continue; + } + final recipient = contactsProvider.findContactByKey(recipientKey); + if (recipient == null) { + continue; + } + final recipientKey6 = recipient.publicKey + .sublist(0, 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + if (recipientKey6 != request.requesterKey6) { + continue; + } + debugPrint( + '📷 [AppProvider] Resolved image requester from sent message metadata ' + 'for session ${request.sessionId}: ${recipient.advName}', + ); + return recipient; + } + + return null; + } + void _scheduleVoiceMissingRetry( String sessionId, { required bool justComplete, @@ -1312,54 +1381,48 @@ class AppProvider with ChangeNotifier { required String sessionId, required int index, Duration timeout = const Duration(seconds: 8), - }) async { - final key = _fragmentAckKey(sessionId, index); - final completer = Completer(); - _voiceFragmentAckWaiters[key] = completer; - try { - await completer.future.timeout(timeout); - return true; - } catch (_) { - if (_voiceFragmentAckWaiters[key] == completer) { - _voiceFragmentAckWaiters.remove(key); - } - return false; - } - } + }) => _voiceFragmentAckWaiters.waitFor( + _fragmentAckKey(sessionId, index), + timeout: timeout, + ); void _completeVoiceFragmentAck(String sessionId, int index) { - final key = _fragmentAckKey(sessionId, index); - final completer = _voiceFragmentAckWaiters.remove(key); - if (completer != null && !completer.isCompleted) { - completer.complete(); + final completed = _voiceFragmentAckWaiters.complete( + _fragmentAckKey(sessionId, index), + ); + if (completed == 0) { + debugPrint( + 'â„šī¸ [AppProvider] Voice fragment ACK had no waiter: $sessionId#$index', + ); + return; } + debugPrint( + '✅ [AppProvider] Voice fragment ACK received for $sessionId#$index ($completed waiter(s))', + ); } Future _waitForImageFragmentAck({ required String sessionId, required int index, Duration timeout = const Duration(seconds: 8), - }) async { - final key = _fragmentAckKey(sessionId, index); - final completer = Completer(); - _imageFragmentAckWaiters[key] = completer; - try { - await completer.future.timeout(timeout); - return true; - } catch (_) { - if (_imageFragmentAckWaiters[key] == completer) { - _imageFragmentAckWaiters.remove(key); - } - return false; - } - } + }) => _imageFragmentAckWaiters.waitFor( + _fragmentAckKey(sessionId, index), + timeout: timeout, + ); void _completeImageFragmentAck(String sessionId, int index) { - final key = _fragmentAckKey(sessionId, index); - final completer = _imageFragmentAckWaiters.remove(key); - if (completer != null && !completer.isCompleted) { - completer.complete(); + final completed = _imageFragmentAckWaiters.complete( + _fragmentAckKey(sessionId, index), + ); + if (completed == 0) { + debugPrint( + 'â„šī¸ [AppProvider] Image fragment ACK had no waiter: $sessionId#$index', + ); + return; } + debugPrint( + '✅ [AppProvider] Image fragment ACK received for $sessionId#$index ($completed waiter(s))', + ); } void _sendVoiceFragmentAck(VoicePacket packet) { diff --git a/lib/providers/helpers/fragment_ack_wait_registry.dart b/lib/providers/helpers/fragment_ack_wait_registry.dart new file mode 100644 index 0000000..2f2711c --- /dev/null +++ b/lib/providers/helpers/fragment_ack_wait_registry.dart @@ -0,0 +1,43 @@ +import 'dart:async'; + +/// Tracks one or more in-flight waiters for the same fragment ACK key. +/// +/// Duplicate fetch requests can race and wait on the same fragment ACK at once. +/// Completing all registered waiters avoids losing the earlier completer when a +/// later request registers for the same key. +class FragmentAckWaitRegistry { + final Map>> _waiters = {}; + + Future waitFor( + String key, { + Duration timeout = const Duration(seconds: 8), + }) async { + final completer = Completer(); + final waiters = _waiters.putIfAbsent(key, () => >[]); + waiters.add(completer); + try { + await completer.future.timeout(timeout); + return true; + } catch (_) { + final pending = _waiters[key]; + pending?.remove(completer); + if (pending != null && pending.isEmpty) { + _waiters.remove(key); + } + return false; + } + } + + int complete(String key) { + final waiters = _waiters.remove(key); + if (waiters == null || waiters.isEmpty) { + return 0; + } + for (final completer in waiters) { + if (!completer.isCompleted) { + completer.complete(); + } + } + return waiters.length; + } +} diff --git a/lib/providers/helpers/session_metadata_restore.dart b/lib/providers/helpers/session_metadata_restore.dart new file mode 100644 index 0000000..892f946 --- /dev/null +++ b/lib/providers/helpers/session_metadata_restore.dart @@ -0,0 +1,38 @@ +import '../../utils/image_message_parser.dart'; +import '../../utils/voice_message_parser.dart'; + +class RestoredSessionMetadata { + final Map voiceSenderKeyBySession; + final Map imageEnvelopeBySession; + + const RestoredSessionMetadata({ + required this.voiceSenderKeyBySession, + required this.imageEnvelopeBySession, + }); +} + +RestoredSessionMetadata restoreSessionMetadataFromMessages( + Iterable messageTexts, +) { + final voiceSenderKeyBySession = {}; + final imageEnvelopeBySession = {}; + + for (final text in messageTexts) { + final voiceEnvelope = VoiceEnvelope.tryParseText(text); + if (voiceEnvelope != null) { + voiceSenderKeyBySession[voiceEnvelope.sessionId] = voiceEnvelope + .senderKey6 + .toLowerCase(); + } + + final imageEnvelope = ImageEnvelope.tryParse(text); + if (imageEnvelope != null) { + imageEnvelopeBySession[imageEnvelope.sessionId] = imageEnvelope; + } + } + + return RestoredSessionMetadata( + voiceSenderKeyBySession: voiceSenderKeyBySession, + imageEnvelopeBySession: imageEnvelopeBySession, + ); +} diff --git a/lib/providers/image_provider.dart b/lib/providers/image_provider.dart index dc93dfd..00a02a7 100644 --- a/lib/providers/image_provider.dart +++ b/lib/providers/image_provider.dart @@ -58,6 +58,7 @@ class ImageProvider with ChangeNotifier { /// Incoming sessions keyed by sessionId. final Map _sessions = {}; + final Set _ignoredIncomingSessions = {}; /// Outgoing sessions cached for deferred serving. final Map _outgoing = {}; @@ -88,6 +89,8 @@ class ImageProvider with ChangeNotifier { bool hasOutgoing(String sessionId) => _outgoing.containsKey(sessionId); Duration? estimateRemainingTransferTime(String sessionId) => _sessions[sessionId]?.estimateRemaining(); + bool isReceiveCanceled(String sessionId) => + _ignoredIncomingSessions.contains(sessionId); List missingFragmentIndices(String sessionId) { final session = _sessions[sessionId]; @@ -107,6 +110,12 @@ class ImageProvider with ChangeNotifier { /// /// Returns true when the session just became complete. bool addFragment(ImagePacket fragment, {int width = 0, int height = 0}) { + if (_ignoredIncomingSessions.contains(fragment.sessionId)) { + debugPrint( + 'âšī¸ [ImageProvider] Ignoring canceled incoming session ${fragment.sessionId}', + ); + return false; + } _sessions.putIfAbsent( fragment.sessionId, () => ImageSession( @@ -135,9 +144,25 @@ class ImageProvider with ChangeNotifier { return justComplete; } + void cancelIncomingSession(String sessionId) { + _ignoredIncomingSessions.add(sessionId); + _sessions.remove(sessionId); + unawaited(_persist()); + notifyListeners(); + } + + void resumeIncomingSession(String sessionId) { + if (_ignoredIncomingSessions.remove(sessionId)) { + notifyListeners(); + } + } + /// Register envelope metadata for a session (called when IE1 is received /// before any binary fragments arrive). void registerEnvelope(ImageEnvelope envelope) { + if (_ignoredIncomingSessions.contains(envelope.sessionId)) { + return; + } final existing = _sessions[envelope.sessionId]; if (existing == null) { _sessions[envelope.sessionId] = ImageSession( @@ -249,6 +274,7 @@ class ImageProvider with ChangeNotifier { Future clearAll() async { _sessions.clear(); _outgoing.clear(); + _ignoredIncomingSessions.clear(); notifyListeners(); try { final prefs = await SharedPreferences.getInstance(); diff --git a/lib/providers/voice_provider.dart b/lib/providers/voice_provider.dart index 33e3d10..96bde9c 100644 --- a/lib/providers/voice_provider.dart +++ b/lib/providers/voice_provider.dart @@ -59,6 +59,7 @@ class VoiceProvider with ChangeNotifier { /// Active sessions keyed by sessionId. final Map _sessions = {}; + final Set _ignoredIncomingSessions = {}; /// Currently playing session ID, or null. String? _playingSessionId; @@ -117,6 +118,8 @@ class VoiceProvider with ChangeNotifier { _outgoingSessions.containsKey(sessionId); Duration? estimateRemainingTransferTime(String sessionId) => _sessions[sessionId]?.estimateRemaining(); + bool isReceiveCanceled(String sessionId) => + _ignoredIncomingSessions.contains(sessionId); List missingPacketIndices(String sessionId) { final session = _sessions[sessionId]; @@ -133,6 +136,12 @@ class VoiceProvider with ChangeNotifier { /// Add an incoming [packet] to its session. Creates the session on first packet. /// Returns true if the session just became complete. bool addPacket(VoicePacket packet) { + if (_ignoredIncomingSessions.contains(packet.sessionId)) { + debugPrint( + 'âšī¸ [VoiceProvider] Ignoring canceled incoming session ${packet.sessionId}', + ); + return false; + } _sessions.putIfAbsent( packet.sessionId, () => VoiceSession( @@ -159,6 +168,23 @@ class VoiceProvider with ChangeNotifier { return justComplete; } + void cancelIncomingSession(String sessionId) { + _ignoredIncomingSessions.add(sessionId); + _sessions.remove(sessionId); + if (_playingSessionId == sessionId) { + unawaited(_player.stop()); + _playingSessionId = null; + } + _persistVoiceData(); + notifyListeners(); + } + + void resumeIncomingSession(String sessionId) { + if (_ignoredIncomingSessions.remove(sessionId)) { + notifyListeners(); + } + } + /// Cache encoded packets for deferred voice serving. void cacheOutgoingSession(String sessionId, List packets) { if (packets.isEmpty) return; @@ -237,6 +263,7 @@ class VoiceProvider with ChangeNotifier { Future clearStoredVoiceData() async { _sessions.clear(); _outgoingSessions.clear(); + _ignoredIncomingSessions.clear(); _playingSessionId = null; notifyListeners(); try { diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 947e939..cbb5394 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -287,6 +287,61 @@ class _MessagesTabState extends State { return 'Select recipient'; } + String _getCurrentScopeLabel() { + if (_destinationType == + MessageDestinationPreferences.destinationTypeChannel) { + final channelName = + _selectedRecipient?.getLocalizedDisplayName(context) ?? + AppLocalizations.of(context)!.publicChannel; + return 'Channel > $channelName'; + } + + if (_destinationType == MessageDestinationPreferences.destinationTypeRoom) { + final roomName = + _selectedRecipient?.displayName ?? + AppLocalizations.of(context)!.messages; + return 'Room > $roomName'; + } + + final contactName = + _selectedRecipient?.displayName ?? + AppLocalizations.of(context)!.messages; + return 'Direct > $contactName'; + } + + Widget _buildScopeIndicator() { + final theme = Theme.of(context); + + return SizedBox( + width: double.infinity, + child: Align( + alignment: Alignment.centerLeft, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.visibility_outlined, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + 'View: ${_getCurrentScopeLabel()}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ); + } + Future _sendMessage() async { final text = _textController.text.trim(); if (text.isEmpty) return; @@ -641,21 +696,8 @@ class _MessagesTabState extends State { 'chunk=${imageDataBytesPerFragment}B', ); - // Push all fragments immediately for direct contacts. - // For channels, fragments are served on demand via IR1 fetch requests. - if (!isChannel && recipient != null) { - // Small delay so the IE1 envelope can propagate before fragments arrive. - await Future.delayed(const Duration(milliseconds: 500)); - if (!mounted) return; - final served = await imageProvider.serveSessionTo( - sessionId: sessionId, - requester: recipient, - ); - debugPrint( - '📷 [Image] Pushed ${served ? fragments.length : 0} ' - 'fragments to ${recipient.advName}', - ); - } + // Image fragments are always served on demand after an explicit IR2 + // fetch request, including direct contacts. } catch (e, st) { debugPrint('❌ [Image] _pickAndSendImage: $e\n$st'); if (!mounted) return; @@ -1571,130 +1613,176 @@ class _MessagesTabState extends State { ), ), ), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - // Quick actions (+) button - IconButton( - icon: Icon(_isRecording ? Icons.stop : Icons.add), - tooltip: _isRecording ? 'Stop recording' : 'More actions', - onPressed: _isRecording - ? _stopAndSendVoice - : _showComposerActions, - style: IconButton.styleFrom( - backgroundColor: Theme.of( - context, - ).colorScheme.primaryContainer, - foregroundColor: _isRecording - ? Colors.red - : Theme.of(context).colorScheme.onPrimaryContainer, + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, ), - ), - const SizedBox(width: 4), - // Destination switcher button - IconButton( - icon: Icon(_getDestinationIcon()), - tooltip: _getDestinationTooltip(), - onPressed: _showRecipientSelector, - style: IconButton.styleFrom( - backgroundColor: - _destinationType == - MessageDestinationPreferences - .destinationTypeChannel - ? Theme.of( + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // Quick actions (+) button + IconButton( + icon: Icon(_isRecording ? Icons.stop : Icons.add), + tooltip: _isRecording + ? 'Stop recording' + : 'More actions', + onPressed: _isRecording + ? _stopAndSendVoice + : _showComposerActions, + style: IconButton.styleFrom( + backgroundColor: Theme.of( context, - ).colorScheme.surfaceContainerHighest - : Theme.of(context).colorScheme.secondaryContainer, - foregroundColor: - _destinationType == - MessageDestinationPreferences - .destinationTypeChannel - ? Theme.of(context).colorScheme.onSurface - : Theme.of(context).colorScheme.onSecondaryContainer, - ), - ), - const SizedBox(width: 4), - // Text field with embedded send button - Expanded( - child: TextField( - controller: _textController, - focusNode: _focusNode, - maxLength: _maxCharacters, - maxLines: null, - maxLengthEnforcement: MaxLengthEnforcement.enforced, - style: const TextStyle(fontSize: 14), - decoration: InputDecoration( - hintText: AppLocalizations.of(context)!.typeYourMessage, - hintStyle: const TextStyle(fontSize: 14), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - isDense: true, - counterText: _characterCount >= 150 - ? '$_characterCount/$_maxCharacters' - : '', - counterStyle: TextStyle( - fontSize: 10, - color: _characterCount > _maxCharacters * 0.9 - ? Colors.orange - : Theme.of(context).textTheme.bodySmall?.color, - ), - suffixIcon: GestureDetector( - onLongPressStart: - (_voiceSupported && !_isSendingVoice) - ? (_) => _startVoiceRecording() - : null, - onLongPressEnd: (_voiceSupported && _isRecording) - ? (_) => _stopAndSendVoice() - : null, - onLongPressCancel: (_voiceSupported && _isRecording) - ? () => _stopAndSendVoice() - : null, - child: IconButton( - icon: _isSendingVoice - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : Icon( - _isRecording - ? Icons.mic - : Icons.send_rounded, - size: 22, - color: _isRecording - ? Colors.red - : (_textController.text.trim().isEmpty - ? Theme.of(context).disabledColor - : Theme.of( - context, - ).colorScheme.primary), - ), - onPressed: - _isRecording || - _isSendingVoice || - _textController.text.trim().isEmpty - ? null - : _sendMessage, - tooltip: _isRecording - ? 'Recording... release to send voice' - : (_isSendingVoice - ? 'Sending voice...' - : _voiceSupported - ? 'Send (long press to record voice)' - : 'Send'), + ).colorScheme.primaryContainer, + foregroundColor: _isRecording + ? Colors.red + : Theme.of( + context, + ).colorScheme.onPrimaryContainer, ), ), - ), - textInputAction: TextInputAction.send, - onSubmitted: (_) => _sendMessage(), + const SizedBox(width: 4), + // Destination switcher button + IconButton( + icon: Icon(_getDestinationIcon()), + tooltip: _getDestinationTooltip(), + onPressed: _showRecipientSelector, + style: IconButton.styleFrom( + backgroundColor: + _destinationType == + MessageDestinationPreferences + .destinationTypeChannel + ? Theme.of( + context, + ).colorScheme.surfaceContainerHighest + : Theme.of( + context, + ).colorScheme.secondaryContainer, + foregroundColor: + _destinationType == + MessageDestinationPreferences + .destinationTypeChannel + ? Theme.of(context).colorScheme.onSurface + : Theme.of( + context, + ).colorScheme.onSecondaryContainer, + ), + ), + const SizedBox(width: 4), + // Scope label + text field share the same alignment + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only( + left: 4, + right: 4, + bottom: 6, + ), + child: _buildScopeIndicator(), + ), + TextField( + controller: _textController, + focusNode: _focusNode, + maxLength: _maxCharacters, + maxLines: null, + maxLengthEnforcement: + MaxLengthEnforcement.enforced, + style: const TextStyle(fontSize: 14), + decoration: InputDecoration( + hintText: AppLocalizations.of( + context, + )!.typeYourMessage, + hintStyle: const TextStyle(fontSize: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + isDense: true, + counterText: _characterCount >= 150 + ? '$_characterCount/$_maxCharacters' + : '', + counterStyle: TextStyle( + fontSize: 10, + color: + _characterCount > _maxCharacters * 0.9 + ? Colors.orange + : Theme.of( + context, + ).textTheme.bodySmall?.color, + ), + suffixIcon: GestureDetector( + onLongPressStart: + (_voiceSupported && !_isSendingVoice) + ? (_) => _startVoiceRecording() + : null, + onLongPressEnd: + (_voiceSupported && _isRecording) + ? (_) => _stopAndSendVoice() + : null, + onLongPressCancel: + (_voiceSupported && _isRecording) + ? () => _stopAndSendVoice() + : null, + child: IconButton( + icon: _isSendingVoice + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Icon( + _isRecording + ? Icons.mic + : Icons.send_rounded, + size: 22, + color: _isRecording + ? Colors.red + : (_textController.text + .trim() + .isEmpty + ? Theme.of( + context, + ).disabledColor + : Theme.of(context) + .colorScheme + .primary), + ), + onPressed: + _isRecording || + _isSendingVoice || + _textController.text + .trim() + .isEmpty + ? null + : _sendMessage, + tooltip: _isRecording + ? 'Recording... release to send voice' + : (_isSendingVoice + ? 'Sending voice...' + : _voiceSupported + ? 'Send (long press to record voice)' + : 'Send'), + ), + ), + ), + textInputAction: TextInputAction.send, + onSubmitted: (_) => _sendMessage(), + ), + ], + ), + ), + ], ), ), ], diff --git a/lib/utils/image_message_parser.dart b/lib/utils/image_message_parser.dart index 54776f2..16bbe68 100644 --- a/lib/utils/image_message_parser.dart +++ b/lib/utils/image_message_parser.dart @@ -125,7 +125,10 @@ int safeImageDataBytesForPath(int pathLen) { ? maxRawPayloadFromCommandFrame : maxRawPayloadFromMesh; final maxData = maxRawPayload - ImagePacket._headerLen; - return maxData.clamp(1, 255).toInt(); + // Keep a safety margin below the theoretical direct-route ceiling. + // Fragments at the absolute 172-byte command-frame limit have proven flaky + // in practice, so cap to the conservative protocol default. + return maxData.clamp(1, ImagePacket.maxDataBytes).toInt(); } /// Approximate end-to-end transmit time for image fragments on MeshCore LoRa. @@ -361,9 +364,7 @@ class ImageFetchRequest { final ts = _parseInt(parts[3], base36: true); final normalizedWant = wantToken == 'a' ? 'all' - : ((wantToken.startsWith('m')) - ? 'missing' - : wantToken); + : ((wantToken.startsWith('m')) ? 'missing' : wantToken); if (sid == null) return null; final missingIndices = []; @@ -532,7 +533,11 @@ String _toBase36(int value) => value.toRadixString(36); String _encodeSessionId(String sessionIdHex) { if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) { - throw ArgumentError.value(sessionIdHex, 'sessionIdHex', 'Expected 8 hex chars'); + throw ArgumentError.value( + sessionIdHex, + 'sessionIdHex', + 'Expected 8 hex chars', + ); } final value = int.parse(sessionIdHex, radix: 16); return value.toRadixString(36); @@ -546,10 +551,7 @@ String? _decodeSessionId(String token) { } String _encodeMissingIndicesCompact(List indices) { - final sorted = indices - .where((v) => v >= 0 && v <= 254) - .toSet() - .toList() + final sorted = indices.where((v) => v >= 0 && v <= 254).toSet().toList() ..sort(); if (sorted.isEmpty) return ''; final chunks = []; @@ -562,7 +564,9 @@ String _encodeMissingIndicesCompact(List indices) { continue; } chunks.add( - start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}', + start == prev + ? _toBase36(start) + : '${_toBase36(start)}-${_toBase36(prev)}', ); start = curr; prev = curr; diff --git a/lib/widgets/messages/image_message_bubble.dart b/lib/widgets/messages/image_message_bubble.dart index cd03bb3..1f8d946 100644 --- a/lib/widgets/messages/image_message_bubble.dart +++ b/lib/widgets/messages/image_message_bubble.dart @@ -68,8 +68,7 @@ class _ImageMessageBubbleState extends State { senderKey6FromEnvelope: envelope.senderKey6, senderName: widget.message.senderName, ); - final effectivePathLen = - sender != null && sender.outPathLen >= 0 + final effectivePathLen = sender != null && sender.outPathLen >= 0 ? sender.outPathLen : widget.message.pathLen; final isComplete = imageProvider.isComplete(envelope.sessionId); @@ -79,7 +78,12 @@ class _ImageMessageBubbleState extends State { if (_isRequesting && isComplete) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) setState(() => _isRequesting = false); + if (mounted) { + setState(() { + _isRequesting = false; + _errorText = null; + }); + } }); } @@ -189,8 +193,38 @@ class _ImageMessageBubbleState extends State { '$received/$total', style: const TextStyle(color: Colors.white, fontSize: 11), ), + Positioned( + top: 8, + right: 8, + child: IconButton( + onPressed: () => _cancelReceive(envelope.sessionId), + icon: const Icon(Icons.close, size: 20), + color: Colors.white70, + tooltip: 'Cancel image receive', + ), + ), ] else if (_errorText != null) ...[ - const Icon(Icons.broken_image, color: Colors.red, size: 36), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.broken_image, color: Colors.red, size: 36), + const SizedBox(height: 8), + TextButton.icon( + onPressed: () => _requestAndFetch( + envelope, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + pathLen: pathLen, + ), + icon: const Icon(Icons.refresh, size: 18), + label: const Text('Retry'), + style: TextButton.styleFrom( + foregroundColor: Colors.white70, + ), + ), + ], + ), ] else ...[ // Tap-to-load icon. IconButton( @@ -221,6 +255,8 @@ class _ImageMessageBubbleState extends State { }) async { if (_isRequesting) return; final conn = context.read(); + final imageProvider = context.read(); + imageProvider.resumeIncomingSession(envelope.sessionId); final contactsProvider = context.read(); final resolution = await TransmissionTargetResolver.resolveFetchTarget( contactsProvider: contactsProvider, @@ -264,7 +300,6 @@ class _ImageMessageBubbleState extends State { } setState(() => _errorText = null); - final imageProvider = context.read(); final deviceKey = conn.deviceInfo.publicKey; if (deviceKey == null || deviceKey.length < 6) { await _showBlockingAlert( @@ -322,7 +357,9 @@ class _ImageMessageBubbleState extends State { if (!mounted) return; // Timeout = 2× estimated LoRa airtime (min 30s). - final effectivePathLen = sender.outPathLen >= 0 ? sender.outPathLen : pathLen; + final effectivePathLen = sender.outPathLen >= 0 + ? sender.outPathLen + : pathLen; final txEstimate = estimateImageTransmitDuration( fragmentCount: missing.isEmpty ? envelope.total : missing.length, sizeBytes: missing.isEmpty @@ -357,6 +394,17 @@ class _ImageMessageBubbleState extends State { ); } + void _cancelReceive(String sessionId) { + if (!mounted) return; + _requestTimeoutTimer?.cancel(); + context.read().cancelIncomingSession(sessionId); + _showToast('Image receive canceled'); + setState(() { + _isRequesting = false; + _errorText = 'Image receive canceled'; + }); + } + Future _showBlockingAlert(String title, String message) async { if (!mounted) return; _showToast('$title: $message'); diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index 8ed9d3a..0e2d331 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -468,7 +468,7 @@ class _MessageBubbleState extends State { 'Sent message: ${widget.message.isSentMessage}', 'Read: ${widget.message.isRead}', 'Status: ${widget.message.deliveryStatus.name}', - 'Path length (nodes/hops): ${widget.message.pathLen}', + 'Path length (nodes/hops): ${_hopDebugLabel(widget.message)}', 'Sender timestamp: ${widget.message.senderTimestamp} (${widget.message.sentAt.toIso8601String()})', 'Received at (RFC3339): ${_formatRfc3339(widget.message.receivedAt)}', 'Channel index: ${widget.message.channelIdx ?? '-'}', @@ -635,8 +635,7 @@ class _MessageBubbleState extends State { _techBadge( context, icon: Icons.route, - label: - '${widget.message.pathLen} hop${widget.message.pathLen == 1 ? '' : 's'}', + label: _hopDisplayLabel(widget.message), ), _techBadge( context, @@ -1527,9 +1526,7 @@ class _MessageBubbleState extends State { required int? rssiDbm, required double? snrDb, }) { - final hopLabel = message.pathLen == 0 - ? 'Direct' - : '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}'; + final hopLabel = _hopDisplayLabel(message); return Wrap( spacing: 4, @@ -1570,6 +1567,21 @@ class _MessageBubbleState extends State { ); } + String _hopDisplayLabel(Message message) { + if (message.pathLen == 0) return 'Direct'; + if (message.pathLen >= 255 && message.isContactMessage) return 'Direct'; + if (message.pathLen >= 255) return 'Unknown'; + return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}'; + } + + String _hopDebugLabel(Message message) { + if (message.pathLen >= 255 && message.isContactMessage) { + return 'Direct (raw: ${message.pathLen})'; + } + if (message.pathLen >= 255) return 'Unknown (raw: ${message.pathLen})'; + return _hopDisplayLabel(message); + } + Widget _techChip( BuildContext context, { required IconData icon, @@ -1736,9 +1748,10 @@ class _MessageBubbleState extends State { : message.getRichDisplayName(senderContact); final l10n = AppLocalizations.of(context)!; - // For sent direct/channel messages, look up destination display label + // Look up destination/source display labels for direct/channel messages dynamic recipientContact; String? recipientDisplayName; + String? channelDisplayName; if (isOwnMessage && message.isContactMessage && message.recipientPublicKey != null) { @@ -1766,103 +1779,123 @@ class _MessageBubbleState extends State { recipientContact.displayName ?? recipientContact.advName; } } - } else if (isOwnMessage && message.isChannelMessage) { + } else if (message.isChannelMessage) { if (message.channelIdx == 0) { - recipientDisplayName = l10n.publicChannel; + channelDisplayName = l10n.publicChannel; } else { final channelContact = contactsProvider.channels.where((c) { return c.publicKey.length > 1 && c.publicKey[1] == message.channelIdx; }).firstOrNull; - recipientDisplayName = + channelDisplayName = channelContact?.getLocalizedDisplayName(context) ?? '${l10n.channel} ${message.channelIdx}'; } + + if (isOwnMessage) { + recipientDisplayName = channelDisplayName; + } } final recipientSubtitle = isOwnMessage && message.isChannelMessage && recipientDisplayName != null ? '${l10n.channel}: $recipientDisplayName' : recipientDisplayName; + final receivedChannelSubtitle = + !isOwnMessage && message.isChannelMessage && channelDisplayName != null + ? '${l10n.channel}: $channelDisplayName' + : null; - return GestureDetector( - onTap: () => _handleBubbleTap( - isSarMarker: isSarMarker, - isDrawing: message.isDrawing, + final shouldFloatBubble = message.isChannelMessage || widget.isCompact; + final bubble = ConstrainedBox( + constraints: BoxConstraints( + maxWidth: shouldFloatBubble + ? MediaQuery.of(context).size.width * 0.78 + : double.infinity, ), - onLongPress: widget.isCompact ? null : () => _showMessageOptions(context), - child: Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: widget.isHighlighted - ? Theme.of(context).colorScheme.primaryContainer - : isSarMarker - ? _getSarMarkerColor(context, isDarkMode) - : message.isDrawing - ? (isDarkMode - ? Theme.of( + child: GestureDetector( + onTap: () => _handleBubbleTap( + isSarMarker: isSarMarker, + isDrawing: message.isDrawing, + ), + onLongPress: widget.isCompact + ? null + : () => _showMessageOptions(context), + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: widget.isHighlighted + ? Theme.of(context).colorScheme.primaryContainer + : isSarMarker + ? _getSarMarkerColor(context, isDarkMode) + : message.isDrawing + ? (isDarkMode + ? Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.15) + : Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.08)) + : _getMessageBubbleColor(context, isOwnMessage, isDarkMode), + borderRadius: BorderRadius.circular(12), + border: widget.isHighlighted + ? Border.all( + color: Theme.of(context).colorScheme.primary, + width: 3, + ) + : isSarMarker + ? Border.all( + color: _getSarMarkerBorderColor(context, isDarkMode), + width: 2, + ) + : message.isDrawing + ? Border.all( + color: Theme.of( context, - ).colorScheme.primary.withValues(alpha: 0.15) - : Theme.of( + ).colorScheme.primary.withValues(alpha: 0.4), + width: 2, + ) + : isOwnMessage + ? Border.all( + color: Theme.of( context, - ).colorScheme.primary.withValues(alpha: 0.08)) - : _getMessageBubbleColor(context, isOwnMessage, isDarkMode), - borderRadius: BorderRadius.circular(12), - border: widget.isHighlighted - ? Border.all( - color: Theme.of(context).colorScheme.primary, - width: 3, - ) - : isSarMarker - ? Border.all( - color: _getSarMarkerBorderColor(context, isDarkMode), - width: 2, - ) - : message.isDrawing - ? Border.all( - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.4), - width: 2, - ) - : isOwnMessage - ? Border.all( - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.3), - width: 1.5, - ) - : !message.isRead && - !message.isSentMessage && - !message.isSystemMessage - ? Border.all(color: Colors.blue, width: 1.5) - : null, - boxShadow: widget.isHighlighted - ? [ - BoxShadow( - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.5), - blurRadius: 12, - spreadRadius: 2, - offset: const Offset(0, 2), - ), - ] - : isSarMarker || message.isDrawing - ? [ - BoxShadow( - color: - (isSarMarker - ? _getSarMarkerBorderColor(context, isDarkMode) - : Theme.of(context).colorScheme.primary) - .withValues(alpha: 0.3), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ] - : null, - ), - child: Column( + ).colorScheme.primary.withValues(alpha: 0.3), + width: 1.5, + ) + : !message.isRead && + !message.isSentMessage && + !message.isSystemMessage + ? Border.all(color: Colors.blue, width: 1.5) + : null, + boxShadow: widget.isHighlighted + ? [ + BoxShadow( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.5), + blurRadius: 12, + spreadRadius: 2, + offset: const Offset(0, 2), + ), + ] + : isSarMarker || message.isDrawing + ? [ + BoxShadow( + color: + (isSarMarker + ? _getSarMarkerBorderColor( + context, + isDarkMode, + ) + : Theme.of(context).colorScheme.primary) + .withValues(alpha: 0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Header: Badge (if SAR or drawing) and time @@ -1984,15 +2017,17 @@ class _MessageBubbleState extends State { maxLines: 1, overflow: TextOverflow.ellipsis, ), - // Show destination for sent direct/channel messages on a separate line - if (isOwnMessage && - recipientSubtitle != null && - !widget.isCompact) ...[ + // Show destination/source context on a separate line. + if (!widget.isCompact && + (recipientSubtitle != null || + receivedChannelSubtitle != null)) ...[ const SizedBox(height: 2), Row( children: [ Icon( - Icons.arrow_forward, + isOwnMessage + ? Icons.arrow_forward + : Icons.arrow_back, size: 12, color: Theme.of(context) .textTheme @@ -2003,7 +2038,9 @@ class _MessageBubbleState extends State { const SizedBox(width: 4), Expanded( child: Text( - recipientSubtitle, + isOwnMessage + ? recipientSubtitle! + : receivedChannelSubtitle!, style: Theme.of(context).textTheme.labelSmall ?.copyWith( color: Theme.of(context) @@ -2460,9 +2497,21 @@ class _MessageBubbleState extends State { ), ], ], - ), + ), + ), ), ); + + if (!shouldFloatBubble) { + return bubble; + } + + return Row( + mainAxisAlignment: isOwnMessage + ? MainAxisAlignment.end + : MainAxisAlignment.start, + children: [Flexible(child: bubble)], + ); } } diff --git a/lib/widgets/messages/voice_message_bubble.dart b/lib/widgets/messages/voice_message_bubble.dart index bdffb3c..dd7eef6 100644 --- a/lib/widgets/messages/voice_message_bubble.dart +++ b/lib/widgets/messages/voice_message_bubble.dart @@ -65,8 +65,7 @@ class _VoiceMessageBubbleState extends State { senderKey6FromEnvelope: envelope?.senderKey6, senderName: widget.message.senderName, ); - final effectivePathLen = - sender != null && sender.outPathLen >= 0 + final effectivePathLen = sender != null && sender.outPathLen >= 0 ? sender.outPathLen : widget.message.pathLen; final isPlaying = voiceProvider.isPlaying(voiceId); @@ -77,6 +76,7 @@ class _VoiceMessageBubbleState extends State { if (!mounted) return; setState(() { _isRequesting = false; + _errorText = null; }); }); } @@ -125,6 +125,10 @@ class _VoiceMessageBubbleState extends State { await voiceProvider.stop(); return; } + if (_isRequesting) { + _cancelReceive(voiceId); + return; + } if (isComplete) { await voiceProvider.play(voiceId); return; @@ -151,7 +155,7 @@ class _VoiceMessageBubbleState extends State { child: Icon( isPlaying ? Icons.stop - : (_isRequesting ? Icons.downloading : Icons.play_arrow), + : (_isRequesting ? Icons.close : Icons.play_arrow), size: 28, color: widget.isSentByMe ? Theme.of(context).colorScheme.onPrimaryContainer @@ -215,6 +219,8 @@ class _VoiceMessageBubbleState extends State { }) async { if (_isRequesting) return; final connectionProvider = context.read(); + final voiceProvider = context.read(); + voiceProvider.resumeIncomingSession(sessionId); final contactsProvider = context.read(); final resolution = await TransmissionTargetResolver.resolveFetchTarget( contactsProvider: contactsProvider, @@ -300,7 +306,9 @@ class _VoiceMessageBubbleState extends State { } // Timeout = 2× estimated LoRa airtime (min 30s). - final effectivePathLen = sender.outPathLen >= 0 ? sender.outPathLen : pathLen; + final effectivePathLen = sender.outPathLen >= 0 + ? sender.outPathLen + : pathLen; final txEstimate = envelope != null ? estimateVoiceTransmitDuration( packetCount: envelope.total, @@ -333,6 +341,18 @@ class _VoiceMessageBubbleState extends State { }); } + void _cancelReceive(String sessionId) { + if (!mounted) return; + _requestTimeoutTimer?.cancel(); + context.read().cancelIncomingSession(sessionId); + _showToast('Voice receive canceled'); + setState(() { + _isRequesting = false; + _autoPlayWhenReady = false; + _errorText = 'Voice receive canceled'; + }); + } + void _showToast(String message) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( diff --git a/pubspec.lock b/pubspec.lock index 816d214..de2cb49 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -883,7 +883,7 @@ packages: description: path: "." ref: main - resolved-ref: "47baadb13d5b1b92d1837042ff6b8a39b685295b" + resolved-ref: a74ac220e7f817ea0bfd3d7108ebbca63fbcb165 url: "https://github.com/dz0ny/meshcore_client.git" source: git version: "0.1.0" diff --git a/test/providers/helpers/fragment_ack_wait_registry_test.dart b/test/providers/helpers/fragment_ack_wait_registry_test.dart new file mode 100644 index 0000000..38be098 --- /dev/null +++ b/test/providers/helpers/fragment_ack_wait_registry_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/providers/helpers/fragment_ack_wait_registry.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('FragmentAckWaitRegistry', () { + test('completes multiple waiters registered for the same key', () async { + final registry = FragmentAckWaitRegistry(); + + final first = registry.waitFor( + 'voice:1', + timeout: const Duration(milliseconds: 200), + ); + final second = registry.waitFor( + 'voice:1', + timeout: const Duration(milliseconds: 200), + ); + + expect(registry.complete('voice:1'), equals(2)); + expect(await first, isTrue); + expect(await second, isTrue); + }); + + test('times out and cleans up a waiter when no ack arrives', () async { + final registry = FragmentAckWaitRegistry(); + + final completed = await registry.waitFor( + 'voice:2', + timeout: const Duration(milliseconds: 20), + ); + + expect(completed, isFalse); + expect(registry.complete('voice:2'), equals(0)); + }); + }); +} diff --git a/test/providers/helpers/session_metadata_restore_test.dart b/test/providers/helpers/session_metadata_restore_test.dart new file mode 100644 index 0000000..e706527 --- /dev/null +++ b/test/providers/helpers/session_metadata_restore_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/providers/helpers/session_metadata_restore.dart'; +import 'package:meshcore_sar_app/utils/image_message_parser.dart'; +import 'package:meshcore_sar_app/utils/voice_message_parser.dart'; + +void main() { + group('restoreSessionMetadataFromMessages', () { + test( + 'restores voice and image session senders from persisted envelopes', + () { + final voiceEnvelope = VoiceEnvelope( + sessionId: '00112233', + mode: VoicePacketMode.mode1200, + total: 4, + durationMs: 4000, + senderKey6: 'AABBCCDDEEFF', + timestampSec: 123456, + ); + final imageEnvelope = ImageEnvelope( + sessionId: '195cb2fb', + format: ImageFormat.avif, + total: 7, + width: 118, + height: 256, + sizeBytes: 1069, + senderKey6: 'FE8B30EE05FC', + timestampSec: 123457, + ); + + final restored = restoreSessionMetadataFromMessages([ + 'plain text', + voiceEnvelope.encodeText(), + imageEnvelope.encode(), + ]); + + expect( + restored.voiceSenderKeyBySession, + equals({'00112233': 'aabbccddeeff'}), + ); + expect(restored.imageEnvelopeBySession.keys, equals({'195cb2fb'})); + expect( + restored.imageEnvelopeBySession['195cb2fb']?.senderKey6, + equals('fe8b30ee05fc'), + ); + }, + ); + + test('keeps latest envelope when a session appears multiple times', () { + final first = ImageEnvelope( + sessionId: '195cb2fb', + format: ImageFormat.avif, + total: 7, + width: 100, + height: 100, + sizeBytes: 900, + senderKey6: '001122334455', + timestampSec: 100, + ); + final second = ImageEnvelope( + sessionId: '195cb2fb', + format: ImageFormat.jpeg, + total: 8, + width: 118, + height: 256, + sizeBytes: 1069, + senderKey6: 'AABBCCDDEEFF', + timestampSec: 101, + ); + + final restored = restoreSessionMetadataFromMessages([ + first.encode(), + second.encode(), + ]); + + expect(restored.imageEnvelopeBySession.length, equals(1)); + expect( + restored.imageEnvelopeBySession['195cb2fb']?.senderKey6, + equals('aabbccddeeff'), + ); + expect( + restored.imageEnvelopeBySession['195cb2fb']?.format, + equals(ImageFormat.jpeg), + ); + }); + }); +} diff --git a/test/providers/image_provider_cancel_test.dart b/test/providers/image_provider_cancel_test.dart new file mode 100644 index 0000000..e60c81a --- /dev/null +++ b/test/providers/image_provider_cancel_test.dart @@ -0,0 +1,52 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/providers/image_provider.dart'; +import 'package:meshcore_sar_app/utils/image_message_parser.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + + group('ImageProvider cancel receive', () { + test('ignores incoming fragments after cancel until resumed', () { + final provider = ImageProvider(); + const sessionId = '01020304'; + const envelope = ImageEnvelope( + sessionId: sessionId, + format: ImageFormat.avif, + total: 2, + width: 32, + height: 32, + sizeBytes: 4, + senderKey6: 'aabbccddeeff', + timestampSec: 1700000000, + ); + final fragment = ImagePacket( + sessionId: sessionId, + format: ImageFormat.avif, + index: 0, + total: 2, + data: Uint8List.fromList([1, 2]), + ); + + provider.registerEnvelope(envelope); + provider.cancelIncomingSession(sessionId); + + expect(provider.isReceiveCanceled(sessionId), isTrue); + expect(provider.session(sessionId), isNull); + + provider.addFragment(fragment, width: 32, height: 32); + + expect(provider.session(sessionId), isNull); + + provider.resumeIncomingSession(sessionId); + provider.registerEnvelope(envelope); + provider.addFragment(fragment, width: 32, height: 32); + + expect(provider.isReceiveCanceled(sessionId), isFalse); + expect(provider.session(sessionId)?.receivedCount, equals(1)); + }); + }); +} diff --git a/test/utils/image_message_parser_test.dart b/test/utils/image_message_parser_test.dart index 20959f9..6c5512d 100644 --- a/test/utils/image_message_parser_test.dart +++ b/test/utils/image_message_parser_test.dart @@ -113,4 +113,17 @@ void main() { expect(parsed.index, equals(9)); }); }); + + group('safeImageDataBytesForPath', () { + test('caps direct-route fragments to conservative default size', () { + expect(safeImageDataBytesForPath(0), equals(ImagePacket.maxDataBytes)); + }); + + test('shrinks for longer paths but never exceeds conservative default', () { + expect( + safeImageDataBytesForPath(2), + lessThanOrEqualTo(ImagePacket.maxDataBytes), + ); + }); + }); }