From 1856dce27a4faab5f529fd18895a7c5f31db4a1f Mon Sep 17 00:00:00 2001 From: Janez T Date: Fri, 6 Mar 2026 21:05:02 +0100 Subject: [PATCH] Update iOS project version --- ios/Runner.xcodeproj/project.pbxproj | 12 +- ios/Runner/Info.plist | 2 +- ios/fastlane/report.xml | 8 +- lib/providers/app_provider.dart | 26 +++++ lib/providers/contacts_provider.dart | 16 +++ .../helpers/message_retry_manager.dart | 23 ++++ lib/providers/messages_provider.dart | 30 +++++ lib/widgets/messages/message_bubble.dart | 103 ++++++++++++++++++ ...messages_provider_retransmission_test.dart | 71 ++++++++++++ 9 files changed, 280 insertions(+), 11 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 91196ab..9e008fc 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -489,7 +489,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 90; + CURRENT_PROJECT_VERSION = 92; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -511,7 +511,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 90; + CURRENT_PROJECT_VERSION = 92; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -530,7 +530,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 90; + CURRENT_PROJECT_VERSION = 92; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -547,7 +547,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 90; + CURRENT_PROJECT_VERSION = 92; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -679,7 +679,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 90; + CURRENT_PROJECT_VERSION = 92; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -702,7 +702,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 90; + CURRENT_PROJECT_VERSION = 92; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index d3886cb..a503442 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -43,7 +43,7 @@ CFBundleSignature ???? CFBundleVersion - 90 + 92 LSRequiresIPhoneOS NSBluetoothAlwaysUsageDescription diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml index 86deef9..e4bef8c 100644 --- a/ios/fastlane/report.xml +++ b/ios/fastlane/report.xml @@ -5,22 +5,22 @@ - + - + - + - + diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 6dedfe4..7c38b3e 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -1041,6 +1041,32 @@ class AppProvider with ChangeNotifier { retryAttempt: retryAttempt, ); }; + + messagesProvider.onDirectPathFailedCallback = + ({required contact, required failureStreak}) async { + debugPrint( + '🧭 [AppProvider] Clearing unhealthy path for ${contact.advName} after $failureStreak failed send chain(s)', + ); + + contactsProvider.markPathUnhealthy(contact.publicKey); + + if (!connectionProvider.deviceInfo.isConnected) { + return; + } + + try { + await connectionProvider.resetPath(contact.publicKey); + Future.delayed(const Duration(milliseconds: 150), () { + if (connectionProvider.deviceInfo.isConnected) { + connectionProvider.getContact(contact.publicKey); + } + }); + } catch (e) { + debugPrint( + '⚠️ [AppProvider] Failed to reset path for ${contact.advName}: $e', + ); + } + }; } /// Initialize the app (load contacts, sync time, etc.) diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index 23a37aa..8377885 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -517,6 +517,22 @@ class ContactsProvider with ChangeNotifier { return _contacts[keyHex]; } + /// Clear a contact's learned path locally so the UI and next send both + /// prefer flood routing until the radio reports a fresh route. + void markPathUnhealthy(Uint8List publicKey) { + final contact = findContactByKey(publicKey); + if (contact == null || !contact.hasPath) { + return; + } + + _contacts[contact.publicKeyHex] = contact.copyWith( + outPathLen: -1, + outPath: Uint8List(0), + ); + _persistContacts(); + notifyListeners(); + } + /// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80). /// Excludes self key and existing contacts. void addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) { diff --git a/lib/providers/helpers/message_retry_manager.dart b/lib/providers/helpers/message_retry_manager.dart index ebfc8d2..a0b6228 100644 --- a/lib/providers/helpers/message_retry_manager.dart +++ b/lib/providers/helpers/message_retry_manager.dart @@ -20,6 +20,7 @@ class MessageRetryManager { // Track retry state for each message ID final Map _retryAttempts = {}; final Map _lastRetryTimes = {}; + final Map _pathFailureStreaks = {}; // Progressive timeout values in milliseconds // These are app-level timeouts, separate from firmware's suggested timeout @@ -120,6 +121,7 @@ class MessageRetryManager { void clearAll() { _retryAttempts.clear(); _lastRetryTimes.clear(); + _pathFailureStreaks.clear(); } /// Get current retry attempt for a message (for debugging) @@ -132,6 +134,27 @@ class MessageRetryManager { return _lastRetryTimes[messageId]; } + /// Record a successful delivery for a contact and clear any accumulated + /// route failure streak for future sends. + void recordDeliverySuccess(Contact contact) { + _pathFailureStreaks.remove(contact.publicKeyHex); + } + + /// Record a permanent route failure for a contact. + /// + /// Returns the updated failure streak so callers can decide when to reset + /// the learned path on the radio and in local state. + int recordPathFailure(Contact contact) { + final contactKey = contact.publicKeyHex; + final next = (_pathFailureStreaks[contactKey] ?? 0) + 1; + _pathFailureStreaks[contactKey] = next; + return next; + } + + int? getPathFailureStreak(Contact contact) { + return _pathFailureStreaks[contact.publicKeyHex]; + } + int _estimateLoRaAirtimeMs(int payloadLenBytes) { final sf = _defaultLoRaSf; final bw = _defaultLoRaBwHz.toDouble(); diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index 0a1fd09..6e52aeb 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -74,6 +74,12 @@ class MessagesProvider with ChangeNotifier { })? sendMessageCallback; + Future Function({ + required Contact contact, + required int failureStreak, + })? + onDirectPathFailedCallback; + List get messages => List.unmodifiable(_messages); List get contactMessages => @@ -1388,6 +1394,10 @@ class MessagesProvider with ChangeNotifier { // Clear retry tracking on successful delivery _retryManager.clearRetry(message.id); + final deliveredContact = _messageContactMap[message.id]; + if (deliveredContact != null) { + _retryManager.recordDeliverySuccess(deliveredContact); + } debugPrint( '✅ [MessagesProvider] Message ${message.id} delivered in ${roundTripTimeMs}ms (ACK $ackCode)', @@ -1427,6 +1437,10 @@ class MessagesProvider with ChangeNotifier { _rememberCompletedAck(ackCode); _clearAckHistoryForMessage(historicalMessageId); _retryManager.clearRetry(historicalMessageId); + final deliveredContact = _messageContactMap[historicalMessageId]; + if (deliveredContact != null) { + _retryManager.recordDeliverySuccess(deliveredContact); + } _persistMessages(); notifyListeners(); debugPrint( @@ -1663,6 +1677,22 @@ class MessagesProvider with ChangeNotifier { // Clear retry tracking _retryManager.clearRetry(messageId); + final failedContact = _messageContactMap[messageId]; + if (failedContact != null && failedContact.hasPath) { + final failureStreak = _retryManager.recordPathFailure(failedContact); + debugPrint( + ' Path failure streak for ${failedContact.advName}: $failureStreak', + ); + if (failureStreak >= 2 && onDirectPathFailedCallback != null) { + unawaited( + onDirectPathFailedCallback!( + contact: failedContact, + failureStreak: failureStreak, + ), + ); + } + } + _persistMessages(); notifyListeners(); } diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index f2acd3c..0a37116 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -574,6 +574,9 @@ class _MessageBubbleState extends State { : null); final rssiDbm = matchedRxLog?.logRxDataInfo?.rssiDbm ?? widget.message.lastEchoRssiDbm; + final retryCause = _retryCauseLabel(widget.message); + final retryResult = _retryResultLabel(widget.message); + final retryMode = _retryModeLabel(widget.message); final rawLines = [ 'Message ID: ${widget.message.id}', @@ -598,6 +601,9 @@ class _MessageBubbleState extends State { 'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}', 'Retry attempt: ${widget.message.retryAttempt}', 'Used flood fallback: ${widget.message.usedFloodFallback}', + 'Retry cause: ${retryCause ?? '-'}', + 'Retry mode: ${retryMode ?? '-'}', + 'Retry result: ${retryResult ?? '-'}', 'Sender key prefix: ${senderPrefixHex ?? '-'}', 'Sender name: ${senderName ?? widget.message.senderName ?? '-'}', 'Sender location at receipt: ${senderLocationSnapshot?.formattedCoordinates ?? '-'}', @@ -848,6 +854,18 @@ class _MessageBubbleState extends State { value: '${widget.message.suggestedTimeoutMs} ms', ), + if (retryCause != null) + _detailRow( + context, + label: 'Retry cause', + value: retryCause, + ), + if (retryMode != null) + _detailRow( + context, + label: 'Retry mode', + value: retryMode, + ), if (widget.message.roundTripTimeMs != null) _detailRow( context, @@ -877,6 +895,12 @@ class _MessageBubbleState extends State { label: l10n.floodFallback, value: l10n.yes, ), + if (retryResult != null) + _detailRow( + context, + label: 'Retry result', + value: retryResult, + ), if (packetPathHex != null) _detailRow( context, @@ -1718,6 +1742,85 @@ class _MessageBubbleState extends State { return _hopDisplayLabel(message); } + String? _retryCauseLabel(Message message) { + if (!message.isContactMessage || message.expectedAckTag == null) { + return null; + } + + if (message.deliveryStatus == MessageDeliveryStatus.sending && + message.retryAttempt == 0) { + return 'Waiting for delivery ACK'; + } + + if (message.retryAttempt > 0 || message.usedFloodFallback) { + return 'Delivery ACK timeout'; + } + + if (message.deliveryStatus == MessageDeliveryStatus.failed) { + return 'Delivery confirmation not received'; + } + + return null; + } + + String? _retryModeLabel(Message message) { + if (!message.isContactMessage) { + return null; + } + + if (message.usedFloodFallback) { + return 'Flood fallback'; + } + + if (message.retryAttempt > 0 || message.expectedAckTag != null) { + return 'Learned direct path'; + } + + return null; + } + + String? _retryResultLabel(Message message) { + if (!message.isContactMessage) { + return null; + } + + if (message.deliveryStatus == MessageDeliveryStatus.delivered) { + if (message.usedFloodFallback) { + return 'Delivered after flood fallback'; + } + if (message.retryAttempt > 0) { + return 'Delivered after retry'; + } + if (message.expectedAckTag != null) { + return 'Delivery confirmed'; + } + } + + if (message.deliveryStatus == MessageDeliveryStatus.sending) { + if (message.usedFloodFallback) { + return 'Flood fallback in progress'; + } + if (message.retryAttempt > 0) { + return 'Retry in progress'; + } + if (message.expectedAckTag != null) { + return 'Awaiting confirmation'; + } + } + + if (message.deliveryStatus == MessageDeliveryStatus.failed) { + if (message.usedFloodFallback) { + return 'Failed after flood fallback'; + } + if (message.retryAttempt > 0) { + return 'Failed after retry attempts'; + } + return 'Delivery failed'; + } + + return null; + } + Widget _techChip( BuildContext context, { required IconData icon, diff --git a/test/providers/messages_provider_retransmission_test.dart b/test/providers/messages_provider_retransmission_test.dart index a30e590..5f0b59e 100644 --- a/test/providers/messages_provider_retransmission_test.dart +++ b/test/providers/messages_provider_retransmission_test.dart @@ -161,5 +161,76 @@ void main() { ); expect(provider.messages.single.roundTripTimeMs, 220); }); + + test('repeated max-retry failures request path reset', () async { + final provider = MessagesProvider(); + final contact = _buildContact(); + final resetRequests = <(String, int)>[]; + provider.onDirectPathFailedCallback = + ({required contact, required failureStreak}) async { + resetRequests.add((contact.advName, failureStreak)); + }; + + provider.addSentMessage( + _buildDirectMessage('m5').copyWith( + retryAttempt: 3, + usedFloodFallback: true, + ), + contact: contact, + ); + provider.markMessageFailed('m5'); + + provider.addSentMessage( + _buildDirectMessage('m6').copyWith( + retryAttempt: 3, + usedFloodFallback: true, + ), + contact: contact, + ); + provider.markMessageFailed('m6'); + + await Future.delayed(Duration.zero); + + expect(resetRequests, [('Teammate', 2)]); + }); + + test('successful delivery clears path failure streak', () async { + final provider = MessagesProvider(); + final contact = _buildContact(); + final resetRequests = []; + provider.onDirectPathFailedCallback = + ({required contact, required failureStreak}) async { + resetRequests.add(failureStreak); + }; + + provider.addSentMessage( + _buildDirectMessage('m7').copyWith( + retryAttempt: 3, + usedFloodFallback: true, + ), + contact: contact, + ); + provider.markMessageFailed('m7'); + + provider.addSentMessage( + _buildDirectMessage('m8'), + contact: contact, + ); + provider.markMessageSent('m8', 123, 10); + provider.markMessageDelivered(123, 150); + + provider.addSentMessage( + _buildDirectMessage('m9').copyWith( + retryAttempt: 3, + usedFloodFallback: true, + ), + contact: contact, + ); + provider.markMessageFailed('m9'); + + await Future.delayed(Duration.zero); + + expect(resetRequests, isEmpty); + }); }); }