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);
+ });
});
}