mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 00:40:28 +00:00
Implement meshcore-open route reload
This commit is contained in:
@@ -12,10 +12,15 @@ import 'image_provider.dart' as ip;
|
||||
import 'helpers/fragment_ack_wait_registry.dart';
|
||||
import 'helpers/session_metadata_restore.dart';
|
||||
import '../services/location_tracking_service.dart';
|
||||
import '../services/messaging_route_preferences.dart';
|
||||
import '../services/nearest_router_selector.dart';
|
||||
import '../services/packet_capture_storage_service.dart';
|
||||
import '../services/path_history_service.dart';
|
||||
import '../services/route_hash_preferences.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/message.dart';
|
||||
import '../models/ble_packet_log.dart';
|
||||
import '../models/path_selection.dart';
|
||||
import '../models/message_reception_details.dart';
|
||||
import '../utils/drawing_message_parser.dart';
|
||||
import '../utils/raw_route_probe.dart';
|
||||
@@ -25,6 +30,31 @@ import '../utils/media_swarm_protocol.dart';
|
||||
import '../utils/message_airtime_estimator.dart';
|
||||
import '../utils/fast_gps_packet.dart';
|
||||
|
||||
class _DirectMessageRouteSession {
|
||||
final PathSelection currentSelection;
|
||||
final ParsedContactRoute? originalRoute;
|
||||
final bool routerFallbackAttempted;
|
||||
|
||||
const _DirectMessageRouteSession({
|
||||
required this.currentSelection,
|
||||
required this.originalRoute,
|
||||
required this.routerFallbackAttempted,
|
||||
});
|
||||
|
||||
_DirectMessageRouteSession copyWith({
|
||||
PathSelection? currentSelection,
|
||||
ParsedContactRoute? originalRoute,
|
||||
bool? routerFallbackAttempted,
|
||||
}) {
|
||||
return _DirectMessageRouteSession(
|
||||
currentSelection: currentSelection ?? this.currentSelection,
|
||||
originalRoute: originalRoute ?? this.originalRoute,
|
||||
routerFallbackAttempted:
|
||||
routerFallbackAttempted ?? this.routerFallbackAttempted,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Main App Provider - coordinates all other providers
|
||||
class AppProvider with ChangeNotifier {
|
||||
static const int _maxDirectPayloadHops = 3;
|
||||
@@ -62,6 +92,17 @@ class AppProvider with ChangeNotifier {
|
||||
bool get isVoiceLimiterEnabled => _isVoiceLimiterEnabled;
|
||||
bool _autoAddDiscoveredContacts = false;
|
||||
bool get autoAddDiscoveredContacts => _autoAddDiscoveredContacts;
|
||||
bool _autoRouteRotationEnabled =
|
||||
MessagingRoutePreferences.defaultAutoRouteRotationEnabled;
|
||||
bool get autoRouteRotationEnabled => _autoRouteRotationEnabled;
|
||||
bool _clearPathOnMaxRetry =
|
||||
MessagingRoutePreferences.defaultClearPathOnMaxRetry;
|
||||
bool get clearPathOnMaxRetry => _clearPathOnMaxRetry;
|
||||
final PathHistoryService _pathHistoryService = PathHistoryService();
|
||||
final NearestRouterSelector _nearestRouterSelector =
|
||||
const NearestRouterSelector();
|
||||
final Map<String, _DirectMessageRouteSession> _directMessageRouteSessions =
|
||||
{};
|
||||
|
||||
static const Duration _packetRetryDelay = Duration(milliseconds: 1200);
|
||||
static const Duration _mediaSwarmResponseWindow = Duration(seconds: 10);
|
||||
@@ -101,6 +142,8 @@ class AppProvider with ChangeNotifier {
|
||||
_loadVoiceCompressorEnabled();
|
||||
_loadVoiceLimiterEnabled();
|
||||
_loadAutoAddDiscoveredContacts();
|
||||
_loadMessagingRouteSettings();
|
||||
unawaited(_pathHistoryService.initialize());
|
||||
_startPacketCapturePersistence();
|
||||
_syncDrawingsOnStartup(); // Sync drawings immediately after providers load
|
||||
_isInitialized = true;
|
||||
@@ -409,6 +452,38 @@ class AppProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMessagingRouteSettings() async {
|
||||
try {
|
||||
_autoRouteRotationEnabled =
|
||||
await MessagingRoutePreferences.getAutoRouteRotationEnabled();
|
||||
_clearPathOnMaxRetry =
|
||||
await MessagingRoutePreferences.getClearPathOnMaxRetry();
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error loading messaging route settings: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> toggleAutoRouteRotationEnabled(bool enabled) async {
|
||||
try {
|
||||
_autoRouteRotationEnabled = enabled;
|
||||
await MessagingRoutePreferences.setAutoRouteRotationEnabled(enabled);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error saving auto route rotation setting: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> toggleClearPathOnMaxRetry(bool enabled) async {
|
||||
try {
|
||||
_clearPathOnMaxRetry = enabled;
|
||||
await MessagingRoutePreferences.setClearPathOnMaxRetry(enabled);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error saving clear path on max retry setting: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize location tracking service
|
||||
Future<void> _initializeLocationTracking() async {
|
||||
try {
|
||||
@@ -488,6 +563,7 @@ class AppProvider with ChangeNotifier {
|
||||
contact,
|
||||
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
||||
);
|
||||
unawaited(_pathHistoryService.recordLearnedPath(contact));
|
||||
|
||||
// Broadcast to SSE clients if server is running
|
||||
connectionProvider.broadcastContactToSseClients(contact);
|
||||
@@ -500,6 +576,9 @@ class AppProvider with ChangeNotifier {
|
||||
contacts,
|
||||
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
||||
);
|
||||
for (final contact in contacts) {
|
||||
unawaited(_pathHistoryService.recordLearnedPath(contact));
|
||||
}
|
||||
debugPrint('Received ${contacts.length} contacts');
|
||||
|
||||
// Broadcast all contacts to SSE clients if server is running
|
||||
@@ -1117,6 +1196,14 @@ class AppProvider with ChangeNotifier {
|
||||
messagesProvider.handleMessageEcho(messageId, echoCount, snrRaw, rssiDbm);
|
||||
};
|
||||
|
||||
connectionProvider.prepareDirectMessageSendCallback =
|
||||
({required messageId, required contact, required retryAttempt}) async {
|
||||
return _prepareDirectMessageSend(
|
||||
messageId: messageId,
|
||||
contact: contact,
|
||||
);
|
||||
};
|
||||
|
||||
// Wire up MessagesProvider's sendMessageCallback for retry logic
|
||||
messagesProvider.sendMessageCallback =
|
||||
({
|
||||
@@ -1134,32 +1221,274 @@ 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)',
|
||||
messagesProvider.onFinalRouterFallbackCallback =
|
||||
({required messageId, required contact, required message}) async {
|
||||
return _sendWithFinalNearestRouterFallback(
|
||||
messageId: messageId,
|
||||
contact: contact,
|
||||
message: message,
|
||||
);
|
||||
|
||||
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',
|
||||
);
|
||||
}
|
||||
};
|
||||
messagesProvider.onFinalDirectMessageFailureCallback =
|
||||
({required messageId, required contact, required message}) async {
|
||||
await _handleDirectMessageFinalFailure(
|
||||
messageId: messageId,
|
||||
contact: contact,
|
||||
);
|
||||
};
|
||||
messagesProvider.onDirectMessageDeliveredCallback =
|
||||
({
|
||||
required messageId,
|
||||
required contact,
|
||||
required message,
|
||||
required roundTripTimeMs,
|
||||
}) {
|
||||
_handleDirectMessageDelivered(
|
||||
messageId: messageId,
|
||||
contact: contact,
|
||||
roundTripTimeMs: roundTripTimeMs,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
Future<Contact> _prepareDirectMessageSend({
|
||||
required String messageId,
|
||||
required Contact contact,
|
||||
}) async {
|
||||
final latestContact =
|
||||
contactsProvider.findContactByKey(contact.publicKey) ?? contact;
|
||||
var session = _directMessageRouteSessions[messageId];
|
||||
if (session == null) {
|
||||
final selection = await _pathHistoryService.getSelectionForContact(
|
||||
latestContact,
|
||||
autoRouteRotationEnabled: _autoRouteRotationEnabled,
|
||||
);
|
||||
session = _DirectMessageRouteSession(
|
||||
currentSelection: selection,
|
||||
originalRoute: ContactRouteCodec.fromContact(latestContact),
|
||||
routerFallbackAttempted: false,
|
||||
);
|
||||
_directMessageRouteSessions[messageId] = session;
|
||||
}
|
||||
|
||||
await _applyPathSelection(
|
||||
latestContact,
|
||||
session.currentSelection,
|
||||
messageId: messageId,
|
||||
routerFallbackAttempted: session.routerFallbackAttempted,
|
||||
);
|
||||
return contactsProvider.findContactByKey(contact.publicKey) ??
|
||||
latestContact;
|
||||
}
|
||||
|
||||
Future<void> _applyPathSelection(
|
||||
Contact contact,
|
||||
PathSelection selection, {
|
||||
required String messageId,
|
||||
required bool routerFallbackAttempted,
|
||||
}) async {
|
||||
final previousRoute = ContactRouteCodec.fromContact(contact);
|
||||
|
||||
try {
|
||||
if (selection.usesFlood) {
|
||||
contactsProvider.resetContactRouteLocal(contact.publicKey);
|
||||
if (connectionProvider.deviceInfo.isConnected) {
|
||||
await connectionProvider.resetPath(contact.publicKey);
|
||||
}
|
||||
} else {
|
||||
final pathDescriptor =
|
||||
((selection.hashSize - 1) << 6) | (selection.hopCount & 0x3F);
|
||||
final signedDescriptor = ContactRouteCodec.toSignedDescriptor(
|
||||
pathDescriptor,
|
||||
);
|
||||
final paddedPathBytes = Uint8List(ContactRouteCodec.maxPathBytes)
|
||||
..setRange(0, selection.pathBytes.length, selection.pathBytes);
|
||||
|
||||
contactsProvider.setContactRouteLocal(
|
||||
contact.publicKey,
|
||||
signedEncodedPathLen: signedDescriptor,
|
||||
paddedPathBytes: paddedPathBytes,
|
||||
);
|
||||
if (connectionProvider.deviceInfo.isConnected) {
|
||||
await connectionProvider.setContactRoute(
|
||||
contact,
|
||||
signedEncodedPathLen: signedDescriptor,
|
||||
paddedPathBytes: paddedPathBytes,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
_restoreRouteLocal(contact.publicKey, previousRoute);
|
||||
rethrow;
|
||||
}
|
||||
|
||||
messagesProvider.updateMessageRouteSelection(
|
||||
messageId,
|
||||
selection,
|
||||
routerFallbackAttempted: routerFallbackAttempted,
|
||||
);
|
||||
}
|
||||
|
||||
void _restoreRouteLocal(Uint8List publicKey, ParsedContactRoute? route) {
|
||||
if (route == null) {
|
||||
contactsProvider.resetContactRouteLocal(publicKey);
|
||||
return;
|
||||
}
|
||||
|
||||
contactsProvider.setContactRouteLocal(
|
||||
publicKey,
|
||||
signedEncodedPathLen: route.signedEncodedPathLen,
|
||||
paddedPathBytes: route.paddedPathBytes,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _restoreRouteOnDevice(
|
||||
Contact contact,
|
||||
ParsedContactRoute? route,
|
||||
) async {
|
||||
_restoreRouteLocal(contact.publicKey, route);
|
||||
if (!connectionProvider.deviceInfo.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (route == null) {
|
||||
await connectionProvider.resetPath(contact.publicKey);
|
||||
return;
|
||||
}
|
||||
|
||||
await connectionProvider.setContactRoute(
|
||||
contact,
|
||||
signedEncodedPathLen: route.signedEncodedPathLen,
|
||||
paddedPathBytes: route.paddedPathBytes,
|
||||
);
|
||||
}
|
||||
|
||||
PathSelection _buildNearestRouterSelection(Contact repeater, int hashSize) {
|
||||
return PathSelection(
|
||||
mode: PathSelectionMode.nearestRouter,
|
||||
pathBytes: Uint8List.fromList(repeater.publicKey.sublist(0, hashSize)),
|
||||
hopCount: 1,
|
||||
hashSize: hashSize,
|
||||
relayName: repeater.advName,
|
||||
relayKey6: _key6(repeater.publicKey),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _sendWithFinalNearestRouterFallback({
|
||||
required String messageId,
|
||||
required Contact contact,
|
||||
required Message message,
|
||||
}) async {
|
||||
final latestContact =
|
||||
contactsProvider.findContactByKey(contact.publicKey) ?? contact;
|
||||
final session =
|
||||
_directMessageRouteSessions[messageId] ??
|
||||
_DirectMessageRouteSession(
|
||||
currentSelection: latestContact.routeHasPath
|
||||
? PathSelection(
|
||||
mode: PathSelectionMode.directCurrent,
|
||||
pathBytes: Uint8List.fromList(latestContact.routePathBytes),
|
||||
hopCount: latestContact.routeHopCount,
|
||||
hashSize: latestContact.routeHashSize,
|
||||
)
|
||||
: PathSelection.flood(),
|
||||
originalRoute: ContactRouteCodec.fromContact(latestContact),
|
||||
routerFallbackAttempted: false,
|
||||
);
|
||||
|
||||
await _pathHistoryService.recordPathResult(
|
||||
latestContact.publicKeyHex,
|
||||
session.currentSelection,
|
||||
success: false,
|
||||
);
|
||||
|
||||
final repeater = _nearestRouterSelector.select(
|
||||
senderPosition: locationTrackingService.currentPosition,
|
||||
repeaters: contactsProvider.repeaters,
|
||||
recipient: latestContact,
|
||||
);
|
||||
if (repeater == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final routeHashSize = await RouteHashPreferences.getHashSize();
|
||||
final fallbackSelection = _buildNearestRouterSelection(
|
||||
repeater,
|
||||
routeHashSize,
|
||||
);
|
||||
_directMessageRouteSessions[messageId] = session.copyWith(
|
||||
currentSelection: fallbackSelection,
|
||||
routerFallbackAttempted: true,
|
||||
);
|
||||
messagesProvider.updateMessageRouteSelection(
|
||||
messageId,
|
||||
fallbackSelection,
|
||||
routerFallbackAttempted: true,
|
||||
);
|
||||
|
||||
return connectionProvider.sendTextMessage(
|
||||
contactPublicKey: latestContact.publicKey,
|
||||
text: message.text,
|
||||
messageId: messageId,
|
||||
contact: latestContact,
|
||||
retryAttempt: message.retryAttempt + 1,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleDirectMessageDelivered({
|
||||
required String messageId,
|
||||
required Contact contact,
|
||||
required int roundTripTimeMs,
|
||||
}) {
|
||||
final session = _directMessageRouteSessions.remove(messageId);
|
||||
if (session == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
unawaited(
|
||||
_pathHistoryService.recordPathResult(
|
||||
contact.publicKeyHex,
|
||||
session.currentSelection,
|
||||
success: true,
|
||||
roundTripTimeMs: roundTripTimeMs,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleDirectMessageFinalFailure({
|
||||
required String messageId,
|
||||
required Contact contact,
|
||||
}) async {
|
||||
final latestContact =
|
||||
contactsProvider.findContactByKey(contact.publicKey) ?? contact;
|
||||
final session = _directMessageRouteSessions.remove(messageId);
|
||||
if (session != null) {
|
||||
await _pathHistoryService.recordPathResult(
|
||||
latestContact.publicKeyHex,
|
||||
session.currentSelection,
|
||||
success: false,
|
||||
);
|
||||
if (session.routerFallbackAttempted) {
|
||||
await _restoreRouteOnDevice(latestContact, session.originalRoute);
|
||||
}
|
||||
}
|
||||
|
||||
if (_clearPathOnMaxRetry) {
|
||||
contactsProvider.resetContactRouteLocal(latestContact.publicKey);
|
||||
if (connectionProvider.deviceInfo.isConnected) {
|
||||
await connectionProvider.resetPath(latestContact.publicKey);
|
||||
Future.delayed(const Duration(milliseconds: 150), () {
|
||||
if (connectionProvider.deviceInfo.isConnected) {
|
||||
connectionProvider.getContact(latestContact.publicKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _key6(Uint8List publicKey) {
|
||||
final bytes = publicKey.sublist(0, math.min(6, publicKey.length));
|
||||
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
|
||||
/// Initialize the app (load contacts, sync time, etc.)
|
||||
|
||||
@@ -175,6 +175,12 @@ class ConnectionProvider with ChangeNotifier {
|
||||
Function(String messageId, int expectedAckTag, int suggestedTimeoutMs)?
|
||||
onMessageSent;
|
||||
Function(int ackCode, int roundTripTimeMs)? onMessageDelivered;
|
||||
Future<Contact?> Function({
|
||||
required String messageId,
|
||||
required Contact contact,
|
||||
required int retryAttempt,
|
||||
})?
|
||||
prepareDirectMessageSendCallback;
|
||||
Function(String messageId, int echoCount, int snrRaw, int rssiDbm)?
|
||||
onMessageEchoDetected;
|
||||
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
|
||||
@@ -1073,7 +1079,7 @@ class ConnectionProvider with ChangeNotifier {
|
||||
///
|
||||
/// [messageId] - optional message ID to track delivery status
|
||||
/// [contact] - optional contact object for path status logging
|
||||
/// [retryAttempt] - retry attempt number (0 = first send, 1-3 = retries)
|
||||
/// [retryAttempt] - retry attempt number (0 = first send, >0 = retries)
|
||||
Future<bool> sendTextMessage({
|
||||
required Uint8List contactPublicKey,
|
||||
required String text,
|
||||
@@ -1087,6 +1093,17 @@ class ConnectionProvider with ChangeNotifier {
|
||||
return false;
|
||||
}
|
||||
|
||||
var effectiveContact = contact;
|
||||
if (messageId != null &&
|
||||
effectiveContact != null &&
|
||||
prepareDirectMessageSendCallback != null) {
|
||||
effectiveContact = await prepareDirectMessageSendCallback!(
|
||||
messageId: messageId,
|
||||
contact: effectiveContact,
|
||||
retryAttempt: retryAttempt,
|
||||
);
|
||||
}
|
||||
|
||||
// CRITICAL: Check firmware ACK limit (8 max in circular buffer)
|
||||
// Rate limit at 7 to stay under the limit
|
||||
if (_messageDeliveryTracker.shouldRateLimit) {
|
||||
@@ -1111,33 +1128,33 @@ class ConnectionProvider with ChangeNotifier {
|
||||
|
||||
try {
|
||||
// Log path status and retry info
|
||||
if (contact != null) {
|
||||
if (effectiveContact != null) {
|
||||
if (retryAttempt > 0) {
|
||||
debugPrint(
|
||||
'🔄 [ConnectionProvider] Sending message to ${contact.advName} (retry $retryAttempt/3)',
|
||||
'🔄 [ConnectionProvider] Sending message to ${effectiveContact.advName} (retry $retryAttempt)',
|
||||
);
|
||||
} else {
|
||||
debugPrint(
|
||||
'📤 [ConnectionProvider] Sending message to ${contact.advName}',
|
||||
'📤 [ConnectionProvider] Sending message to ${effectiveContact.advName}',
|
||||
);
|
||||
}
|
||||
debugPrint(' Type: ${contact.type.displayName}');
|
||||
debugPrint(' Path status: ${contact.routeSummary}');
|
||||
if (contact.routeHasPath) {
|
||||
debugPrint(' Type: ${effectiveContact.type.displayName}');
|
||||
debugPrint(' Path status: ${effectiveContact.routeSummary}');
|
||||
if (effectiveContact.routeHasPath) {
|
||||
debugPrint(
|
||||
' ✅ Using learned path (${contact.routeHopCount} hop(s), ${contact.routeHashSize}-byte hashes)',
|
||||
' ✅ Using learned path (${effectiveContact.routeHopCount} hop(s), ${effectiveContact.routeHashSize}-byte hashes)',
|
||||
);
|
||||
} else {
|
||||
debugPrint(' ⚠️ No path available - will use flood mode');
|
||||
}
|
||||
} else if (retryAttempt > 0) {
|
||||
debugPrint(
|
||||
'🔄 [ConnectionProvider] Sending message (retry $retryAttempt/3)',
|
||||
'🔄 [ConnectionProvider] Sending message (retry $retryAttempt)',
|
||||
);
|
||||
}
|
||||
|
||||
// Track pending operation for auto-recovery (if contact not found in radio)
|
||||
if (contact != null) {
|
||||
if (effectiveContact != null) {
|
||||
final operationId = contactPublicKey
|
||||
.sublist(0, 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
@@ -1146,7 +1163,7 @@ class ConnectionProvider with ChangeNotifier {
|
||||
contactPublicKey: contactPublicKey,
|
||||
text: text,
|
||||
messageId: messageId,
|
||||
contact: contact,
|
||||
contact: effectiveContact,
|
||||
retryAttempt: retryAttempt,
|
||||
);
|
||||
debugPrint(
|
||||
@@ -1191,7 +1208,7 @@ class ConnectionProvider with ChangeNotifier {
|
||||
|
||||
// Clear pending operation after successful send (no error)
|
||||
// If ERR_CODE_NOT_FOUND occurs, the operation will be recovered automatically
|
||||
if (contact != null) {
|
||||
if (effectiveContact != null) {
|
||||
final operationId = contactPublicKey
|
||||
.sublist(0, 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
|
||||
@@ -5,15 +5,13 @@ import '../../models/contact.dart';
|
||||
|
||||
/// Manages message retry state and logic
|
||||
///
|
||||
/// This helper class centralizes retry logic for direct messages, implementing
|
||||
/// a progressive timeout strategy (4s, 8s, 12s) for messages sent to contacts
|
||||
/// with learned routing paths.
|
||||
/// This helper class centralizes retry logic for direct messages.
|
||||
///
|
||||
/// IMPORTANT: Based on MeshCore firmware analysis:
|
||||
/// - Firmware calculates timeout based on path length and airtime
|
||||
/// - Direct mode: ~(path_len * airtime * 2) + margin
|
||||
/// - Flood mode: ~10-30 seconds for multi-hop
|
||||
/// - Our timeouts (4s, 8s, 12s) are conservative for direct paths
|
||||
/// - Our retry delays (1s, 2s, 4s, 8s) are app-level backoff timers
|
||||
/// - Firmware does NOT automatically retry - app must implement
|
||||
class MessageRetryManager {
|
||||
// Track retry state for each message ID
|
||||
@@ -21,10 +19,10 @@ class MessageRetryManager {
|
||||
final Map<String, DateTime> _lastRetryTimes = {};
|
||||
final Map<String, int> _pathFailureStreaks = {};
|
||||
|
||||
// Progressive timeout values in milliseconds
|
||||
// These are app-level timeouts, separate from firmware's suggested timeout
|
||||
// Firmware timeout is for ACK arrival, these are for retry attempts
|
||||
static const List<int> _timeouts = [4000, 8000, 12000];
|
||||
static const int maxRetryAttempts = 4;
|
||||
|
||||
// Retry backoff values in milliseconds.
|
||||
static const List<int> _retryDelays = [1000, 2000, 4000, 8000];
|
||||
static const int _defaultLoRaSf = 10;
|
||||
static const int _defaultLoRaCr = 5;
|
||||
static const int _defaultLoRaBwHz = 250000;
|
||||
@@ -32,13 +30,12 @@ class MessageRetryManager {
|
||||
static const int _defaultLoRaCrcEnabled = 1;
|
||||
static const int _defaultLoRaExplicitHeader = 1;
|
||||
|
||||
/// Get timeout for a specific retry attempt (0-2)
|
||||
/// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2
|
||||
int getTimeoutForAttempt(int attempt) {
|
||||
if (attempt < 0 || attempt >= _timeouts.length) {
|
||||
return _timeouts.last; // Default to last timeout if out of range
|
||||
/// Get backoff delay for the next retry attempt.
|
||||
int getDelayForAttempt(int attempt) {
|
||||
if (attempt < 0 || attempt >= _retryDelays.length) {
|
||||
return _retryDelays.last;
|
||||
}
|
||||
return _timeouts[attempt];
|
||||
return _retryDelays[attempt];
|
||||
}
|
||||
|
||||
/// Calculate a conservative delivery-ACK timeout when firmware doesn't
|
||||
@@ -65,43 +62,8 @@ class MessageRetryManager {
|
||||
return ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
|
||||
}
|
||||
|
||||
/// Check if a message is eligible for retry
|
||||
///
|
||||
/// Returns true if:
|
||||
/// - The message has retryAttempt < 3
|
||||
/// - The contact has a learned path (contact.hasPath == true)
|
||||
/// - The message hasn't used flood fallback yet
|
||||
///
|
||||
/// Messages to contacts without paths should NOT retry (flood mode already broadcasts)
|
||||
bool canRetry(Message message, Contact contact) {
|
||||
// Never retry if already tried flood mode
|
||||
if (message.usedFloodFallback) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Never retry beyond 3 attempts
|
||||
if (message.retryAttempt >= 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only retry if contact has a learned path
|
||||
// If no path, the device uses flood mode automatically - retrying won't help
|
||||
return contact.routeHasPath;
|
||||
}
|
||||
|
||||
/// Check if should fall back to flood mode
|
||||
///
|
||||
/// Returns true if:
|
||||
/// - Message has exhausted all 3 retry attempts with direct mode
|
||||
/// - Contact HAS a learned path (so direct mode was used)
|
||||
/// - Hasn't already used flood fallback
|
||||
///
|
||||
/// IMPORTANT: Only contacts WITH paths need flood fallback.
|
||||
/// Contacts without paths already use flood mode automatically.
|
||||
bool shouldUseFloodFallback(Message message, Contact contact) {
|
||||
return message.retryAttempt >= 3 &&
|
||||
contact.routeHasPath &&
|
||||
!message.usedFloodFallback;
|
||||
return message.retryAttempt < maxRetryAttempts;
|
||||
}
|
||||
|
||||
/// Track a retry attempt for a message
|
||||
|
||||
@@ -5,6 +5,8 @@ import '../models/contact.dart';
|
||||
import '../models/message_contact_location.dart';
|
||||
import '../models/message_reception_details.dart';
|
||||
import '../models/message_transfer_details.dart';
|
||||
import '../models/message_route_metadata.dart';
|
||||
import '../models/path_selection.dart';
|
||||
import '../models/sar_marker.dart';
|
||||
import '../models/map_drawing.dart';
|
||||
import '../services/message_storage_service.dart';
|
||||
@@ -27,6 +29,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
final Map<String, MessageContactLocation> _messageContactLocations = {};
|
||||
final Map<String, MessageReceptionDetails> _messageReceptionDetails = {};
|
||||
final Map<String, MessageTransferDetails> _messageTransferDetails = {};
|
||||
final Map<String, MessageRouteMetadata> _messageRouteMetadata = {};
|
||||
|
||||
// Track pending sent messages by expected ACK/TAG
|
||||
final Map<int, Message> _pendingSentMessages = {};
|
||||
@@ -81,6 +84,25 @@ class MessagesProvider with ChangeNotifier {
|
||||
|
||||
Future<void> Function({required Contact contact, required int failureStreak})?
|
||||
onDirectPathFailedCallback;
|
||||
Future<bool> Function({
|
||||
required String messageId,
|
||||
required Contact contact,
|
||||
required Message message,
|
||||
})?
|
||||
onFinalRouterFallbackCallback;
|
||||
Future<void> Function({
|
||||
required String messageId,
|
||||
required Contact contact,
|
||||
required Message message,
|
||||
})?
|
||||
onFinalDirectMessageFailureCallback;
|
||||
void Function({
|
||||
required String messageId,
|
||||
required Contact contact,
|
||||
required Message message,
|
||||
required int roundTripTimeMs,
|
||||
})?
|
||||
onDirectMessageDeliveredCallback;
|
||||
|
||||
String? Function(Uint8List? publicKey)? resolveContactNameCallback;
|
||||
String Function(int channelIdx)? resolveChannelNameCallback;
|
||||
@@ -126,6 +148,30 @@ class MessagesProvider with ChangeNotifier {
|
||||
MessageTransferDetails? getMessageTransferDetails(String messageId) =>
|
||||
_messageTransferDetails[messageId];
|
||||
|
||||
MessageRouteMetadata? getMessageRouteMetadata(String messageId) =>
|
||||
_messageRouteMetadata[messageId];
|
||||
|
||||
void updateMessageRouteSelection(
|
||||
String messageId,
|
||||
PathSelection selection, {
|
||||
required bool routerFallbackAttempted,
|
||||
}) {
|
||||
_messageRouteMetadata[messageId] = MessageRouteMetadata.fromSelection(
|
||||
selection,
|
||||
routerFallbackAttempted: routerFallbackAttempted,
|
||||
);
|
||||
|
||||
final index = _messages.indexWhere((message) => message.id == messageId);
|
||||
if (index != -1) {
|
||||
_messages[index] = _messages[index].copyWith(
|
||||
usedFloodFallback: selection.usesFlood,
|
||||
);
|
||||
}
|
||||
|
||||
_persistMessages();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Set localizations for notifications
|
||||
void setLocalizations(AppLocalizations localizations) {
|
||||
_localizations = localizations;
|
||||
@@ -160,6 +206,8 @@ class MessagesProvider with ChangeNotifier {
|
||||
.loadMessageReceptionDetails();
|
||||
final storedTransferDetails = await _storageService
|
||||
.loadMessageTransferDetails();
|
||||
final storedRouteMetadata = await _storageService
|
||||
.loadMessageRouteMetadata();
|
||||
_messageContactLocations
|
||||
..clear()
|
||||
..addAll(storedContactLocations);
|
||||
@@ -169,6 +217,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
_messageTransferDetails
|
||||
..clear()
|
||||
..addAll(storedTransferDetails);
|
||||
_messageRouteMetadata
|
||||
..clear()
|
||||
..addAll(storedRouteMetadata);
|
||||
|
||||
// Add stored messages with enhancement to ensure SAR detection
|
||||
for (final message in storedMessages) {
|
||||
@@ -688,6 +739,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
messageContactLocations: _messageContactLocations,
|
||||
messageReceptionDetails: _messageReceptionDetails,
|
||||
messageTransferDetails: _messageTransferDetails,
|
||||
messageRouteMetadata: _messageRouteMetadata,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
|
||||
@@ -806,6 +858,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
_messageContactLocations.remove(messageId);
|
||||
_messageReceptionDetails.remove(messageId);
|
||||
_messageTransferDetails.remove(messageId);
|
||||
_messageRouteMetadata.remove(messageId);
|
||||
|
||||
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
|
||||
|
||||
@@ -837,6 +890,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
_messageContactLocations.clear();
|
||||
_messageReceptionDetails.clear();
|
||||
_messageTransferDetails.clear();
|
||||
_messageRouteMetadata.clear();
|
||||
_persistMessages();
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -854,6 +908,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
_messageContactLocations.clear();
|
||||
_messageReceptionDetails.clear();
|
||||
_messageTransferDetails.clear();
|
||||
_messageRouteMetadata.clear();
|
||||
_persistMessages();
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -1549,6 +1604,12 @@ class MessagesProvider with ChangeNotifier {
|
||||
final deliveredContact = _messageContactMap[message.id];
|
||||
if (deliveredContact != null) {
|
||||
_retryManager.recordDeliverySuccess(deliveredContact);
|
||||
onDirectMessageDeliveredCallback?.call(
|
||||
messageId: message.id,
|
||||
contact: deliveredContact,
|
||||
message: updatedMessage,
|
||||
roundTripTimeMs: roundTripTimeMs,
|
||||
);
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
@@ -1592,6 +1653,12 @@ class MessagesProvider with ChangeNotifier {
|
||||
final deliveredContact = _messageContactMap[historicalMessageId];
|
||||
if (deliveredContact != null) {
|
||||
_retryManager.recordDeliverySuccess(deliveredContact);
|
||||
onDirectMessageDeliveredCallback?.call(
|
||||
messageId: historicalMessageId,
|
||||
contact: deliveredContact,
|
||||
message: _messages[historicalIndex],
|
||||
roundTripTimeMs: roundTripTimeMs,
|
||||
);
|
||||
}
|
||||
_persistMessages();
|
||||
notifyListeners();
|
||||
@@ -1677,29 +1744,29 @@ class MessagesProvider with ChangeNotifier {
|
||||
debugPrint(' Contact has path: ${contact?.routeHasPath ?? false}');
|
||||
debugPrint(' Used flood fallback: ${message.usedFloodFallback}');
|
||||
|
||||
// Decision tree for retry/flood/fail
|
||||
final routeMetadata = _messageRouteMetadata[messageId];
|
||||
final routerFallbackAttempted =
|
||||
routeMetadata?.routerFallbackAttempted ?? false;
|
||||
|
||||
// Decision tree for retry/final-router-fallback/fail
|
||||
if (contact != null && _retryManager.canRetry(message, contact)) {
|
||||
// RETRY: Contact has path and retry attempts < 3
|
||||
_scheduleRetry(messageId, message, contact);
|
||||
} else if (contact != null &&
|
||||
_retryManager.shouldUseFloodFallback(message, contact)) {
|
||||
// FLOOD FALLBACK: After 3 retries failed, try flood once
|
||||
_sendWithFloodMode(messageId, message, contact);
|
||||
} else if (contact != null && !routerFallbackAttempted) {
|
||||
unawaited(_sendWithFinalRouterFallback(messageId, message, contact));
|
||||
} else {
|
||||
// PERMANENTLY FAILED: No retry possible
|
||||
_markAsPermanentlyFailed(messageId, message);
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule a retry with progressive timeout
|
||||
/// Schedule a retry with exponential backoff.
|
||||
void _scheduleRetry(String messageId, Message message, Contact contact) {
|
||||
final nextAttempt = message.retryAttempt + 1;
|
||||
final timeout = _retryManager.getTimeoutForAttempt(message.retryAttempt);
|
||||
final delayMs = _retryManager.getDelayForAttempt(message.retryAttempt);
|
||||
|
||||
debugPrint(
|
||||
'🔄 [MessagesProvider] Scheduling retry $nextAttempt/3 for message $messageId',
|
||||
'🔄 [MessagesProvider] Scheduling retry $nextAttempt/${MessageRetryManager.maxRetryAttempts} for message $messageId',
|
||||
);
|
||||
debugPrint(' Timeout: ${timeout}ms');
|
||||
debugPrint(' Delay: ${delayMs}ms');
|
||||
|
||||
// Update message with new retry attempt
|
||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||
@@ -1720,10 +1787,10 @@ class MessagesProvider with ChangeNotifier {
|
||||
// Track retry
|
||||
_retryManager.trackRetry(messageId, nextAttempt);
|
||||
|
||||
notifyListeners(); // Update UI to show "Retrying (X/3)..."
|
||||
notifyListeners();
|
||||
|
||||
// Schedule actual retry after delay
|
||||
Timer(Duration(milliseconds: timeout), () async {
|
||||
Timer(Duration(milliseconds: delayMs), () async {
|
||||
debugPrint(
|
||||
'⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId',
|
||||
);
|
||||
@@ -1759,49 +1826,46 @@ class MessagesProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Send message with flood mode as last resort
|
||||
Future<void> _sendWithFloodMode(
|
||||
Future<void> _sendWithFinalRouterFallback(
|
||||
String messageId,
|
||||
Message message,
|
||||
Contact contact,
|
||||
) async {
|
||||
debugPrint(
|
||||
'🌊 [MessagesProvider] Trying flood mode for message $messageId',
|
||||
'🛟 [MessagesProvider] Trying final router fallback for $messageId',
|
||||
);
|
||||
|
||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||
if (index != -1) {
|
||||
_messages[index] = message.copyWith(
|
||||
usedFloodFallback: true,
|
||||
deliveryStatus: MessageDeliveryStatus.sending,
|
||||
lastRetryAt: DateTime.now(),
|
||||
);
|
||||
|
||||
// Cancel old timeout timer
|
||||
_timeoutTimers[message.id]?.cancel();
|
||||
_timeoutTimers.remove(message.id);
|
||||
if (message.expectedAckTag != null) {
|
||||
_pendingSentMessages.remove(message.expectedAckTag);
|
||||
}
|
||||
_clearAckHistoryForMessage(messageId);
|
||||
|
||||
notifyListeners();
|
||||
|
||||
// Send with flood mode (no retry after this)
|
||||
if (sendMessageCallback != null) {
|
||||
final queued = await sendMessageCallback!(
|
||||
contactPublicKey: contact.publicKey,
|
||||
text: message.text,
|
||||
messageId: messageId,
|
||||
contact: contact,
|
||||
retryAttempt: 0, // Reset attempt for flood
|
||||
);
|
||||
if (!queued) {
|
||||
_markAsPermanentlyFailed(messageId, _messages[index]);
|
||||
}
|
||||
} else {
|
||||
if (onFinalRouterFallbackCallback == null) {
|
||||
debugPrint(
|
||||
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot send flood',
|
||||
'⚠️ [MessagesProvider] onFinalRouterFallbackCallback not set',
|
||||
);
|
||||
_markAsPermanentlyFailed(messageId, _messages[index]);
|
||||
return;
|
||||
}
|
||||
|
||||
final queued = await onFinalRouterFallbackCallback!(
|
||||
messageId: messageId,
|
||||
contact: contact,
|
||||
message: _messages[index],
|
||||
);
|
||||
if (!queued) {
|
||||
_markAsPermanentlyFailed(messageId, _messages[index]);
|
||||
}
|
||||
|
||||
_persistMessages();
|
||||
@@ -1830,19 +1894,15 @@ class MessagesProvider with ChangeNotifier {
|
||||
_retryManager.clearRetry(messageId);
|
||||
|
||||
final failedContact = _messageContactMap[messageId];
|
||||
if (failedContact != null && failedContact.routeHasPath) {
|
||||
final failureStreak = _retryManager.recordPathFailure(failedContact);
|
||||
debugPrint(
|
||||
' Path failure streak for ${failedContact.advName}: $failureStreak',
|
||||
if (failedContact != null &&
|
||||
onFinalDirectMessageFailureCallback != null) {
|
||||
unawaited(
|
||||
onFinalDirectMessageFailureCallback!(
|
||||
messageId: messageId,
|
||||
contact: failedContact,
|
||||
message: _messages[index],
|
||||
),
|
||||
);
|
||||
if (failureStreak >= 2 && onDirectPathFailedCallback != null) {
|
||||
unawaited(
|
||||
onDirectPathFailedCallback!(
|
||||
contact: failedContact,
|
||||
failureStreak: failureStreak,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_persistMessages();
|
||||
@@ -1870,6 +1930,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
}
|
||||
_clearAckHistoryForMessage(messageId);
|
||||
_retryManager.clearRetry(messageId);
|
||||
_messageRouteMetadata.remove(messageId);
|
||||
|
||||
_messages[index] = Message(
|
||||
id: message.id,
|
||||
|
||||
Reference in New Issue
Block a user