fix: Widen worker stats graph

This commit is contained in:
Janez T
2026-04-04 20:20:36 +02:00
parent 5320fa1a2b
commit 1baab06627
13 changed files with 355 additions and 1345 deletions

View File

@@ -116,7 +116,6 @@ class AppSettingsProfileSection {
final bool? voiceEchoCancellationEnabled; final bool? voiceEchoCancellationEnabled;
final bool? voiceNoiseSuppressionEnabled; final bool? voiceNoiseSuppressionEnabled;
final double? messageFontScale; final double? messageFontScale;
final bool? autoRouteRotationEnabled;
final bool? clearPathOnMaxRetry; final bool? clearPathOnMaxRetry;
final bool? nearestRelayFallbackEnabled; final bool? nearestRelayFallbackEnabled;
final int? voiceBitrate; final int? voiceBitrate;
@@ -142,7 +141,6 @@ class AppSettingsProfileSection {
this.voiceEchoCancellationEnabled, this.voiceEchoCancellationEnabled,
this.voiceNoiseSuppressionEnabled, this.voiceNoiseSuppressionEnabled,
this.messageFontScale, this.messageFontScale,
this.autoRouteRotationEnabled,
this.clearPathOnMaxRetry, this.clearPathOnMaxRetry,
this.nearestRelayFallbackEnabled, this.nearestRelayFallbackEnabled,
this.voiceBitrate, this.voiceBitrate,
@@ -169,7 +167,6 @@ class AppSettingsProfileSection {
voiceEchoCancellationEnabled == null && voiceEchoCancellationEnabled == null &&
voiceNoiseSuppressionEnabled == null && voiceNoiseSuppressionEnabled == null &&
messageFontScale == null && messageFontScale == null &&
autoRouteRotationEnabled == null &&
clearPathOnMaxRetry == null && clearPathOnMaxRetry == null &&
nearestRelayFallbackEnabled == null && nearestRelayFallbackEnabled == null &&
voiceBitrate == null && voiceBitrate == null &&
@@ -195,7 +192,6 @@ class AppSettingsProfileSection {
'voiceEchoCancellationEnabled': voiceEchoCancellationEnabled, 'voiceEchoCancellationEnabled': voiceEchoCancellationEnabled,
'voiceNoiseSuppressionEnabled': voiceNoiseSuppressionEnabled, 'voiceNoiseSuppressionEnabled': voiceNoiseSuppressionEnabled,
'messageFontScale': messageFontScale, 'messageFontScale': messageFontScale,
'autoRouteRotationEnabled': autoRouteRotationEnabled,
'clearPathOnMaxRetry': clearPathOnMaxRetry, 'clearPathOnMaxRetry': clearPathOnMaxRetry,
'nearestRelayFallbackEnabled': nearestRelayFallbackEnabled, 'nearestRelayFallbackEnabled': nearestRelayFallbackEnabled,
'voiceBitrate': voiceBitrate, 'voiceBitrate': voiceBitrate,
@@ -225,7 +221,6 @@ class AppSettingsProfileSection {
voiceNoiseSuppressionEnabled: voiceNoiseSuppressionEnabled:
json['voiceNoiseSuppressionEnabled'] as bool?, json['voiceNoiseSuppressionEnabled'] as bool?,
messageFontScale: (json['messageFontScale'] as num?)?.toDouble(), messageFontScale: (json['messageFontScale'] as num?)?.toDouble(),
autoRouteRotationEnabled: json['autoRouteRotationEnabled'] as bool?,
clearPathOnMaxRetry: json['clearPathOnMaxRetry'] as bool?, clearPathOnMaxRetry: json['clearPathOnMaxRetry'] as bool?,
nearestRelayFallbackEnabled: json['nearestRelayFallbackEnabled'] as bool?, nearestRelayFallbackEnabled: json['nearestRelayFallbackEnabled'] as bool?,
voiceBitrate: json['voiceBitrate'] as int?, voiceBitrate: json['voiceBitrate'] as int?,

View File

@@ -1,229 +0,0 @@
enum PathRecordSource { learned, observed }
class PathRecord {
final List<int> pathBytes;
final int hopCount;
final int hashSize;
final PathRecordSource source;
final int successCount;
final int failureCount;
final int lastRoundTripTimeMs;
final DateTime lastUsedAt;
final DateTime? lastSucceededAt;
final double? senderLatitude;
final double? senderLongitude;
final double? recipientLatitude;
final double? recipientLongitude;
const PathRecord({
required this.pathBytes,
required this.hopCount,
required this.hashSize,
required this.source,
required this.successCount,
required this.failureCount,
required this.lastRoundTripTimeMs,
required this.lastUsedAt,
required this.lastSucceededAt,
required this.senderLatitude,
required this.senderLongitude,
required this.recipientLatitude,
required this.recipientLongitude,
});
String get signature =>
pathBytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
double get successRate =>
(successCount + 1) / (successCount + failureCount + 2);
PathRecord copyWith({
List<int>? pathBytes,
int? hopCount,
int? hashSize,
PathRecordSource? source,
int? successCount,
int? failureCount,
int? lastRoundTripTimeMs,
DateTime? lastUsedAt,
DateTime? lastSucceededAt,
double? senderLatitude,
double? senderLongitude,
double? recipientLatitude,
double? recipientLongitude,
}) {
return PathRecord(
pathBytes: pathBytes ?? this.pathBytes,
hopCount: hopCount ?? this.hopCount,
hashSize: hashSize ?? this.hashSize,
source: source ?? this.source,
successCount: successCount ?? this.successCount,
failureCount: failureCount ?? this.failureCount,
lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs,
lastUsedAt: lastUsedAt ?? this.lastUsedAt,
lastSucceededAt: lastSucceededAt ?? this.lastSucceededAt,
senderLatitude: senderLatitude ?? this.senderLatitude,
senderLongitude: senderLongitude ?? this.senderLongitude,
recipientLatitude: recipientLatitude ?? this.recipientLatitude,
recipientLongitude: recipientLongitude ?? this.recipientLongitude,
);
}
Map<String, dynamic> toJson() {
return {
'path_bytes': pathBytes,
'hop_count': hopCount,
'hash_size': hashSize,
'source': source.name,
'success_count': successCount,
'failure_count': failureCount,
'last_round_trip_time_ms': lastRoundTripTimeMs,
'last_used_at': lastUsedAt.toIso8601String(),
'last_succeeded_at': lastSucceededAt?.toIso8601String(),
'sender_latitude': senderLatitude,
'sender_longitude': senderLongitude,
'recipient_latitude': recipientLatitude,
'recipient_longitude': recipientLongitude,
};
}
factory PathRecord.fromJson(Map<String, dynamic> json) {
return PathRecord(
pathBytes: (json['path_bytes'] as List<dynamic>? ?? const <dynamic>[])
.map((value) => value as int)
.toList(),
hopCount: json['hop_count'] as int? ?? 0,
hashSize: json['hash_size'] as int? ?? 1,
source: PathRecordSource.values.firstWhere(
(value) => value.name == (json['source'] as String? ?? 'learned'),
orElse: () => PathRecordSource.learned,
),
successCount: json['success_count'] as int? ?? 0,
failureCount: json['failure_count'] as int? ?? 0,
lastRoundTripTimeMs: json['last_round_trip_time_ms'] as int? ?? 0,
lastUsedAt:
DateTime.tryParse(json['last_used_at'] as String? ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0),
lastSucceededAt: DateTime.tryParse(
json['last_succeeded_at'] as String? ?? '',
),
senderLatitude: (json['sender_latitude'] as num?)?.toDouble(),
senderLongitude: (json['sender_longitude'] as num?)?.toDouble(),
recipientLatitude: (json['recipient_latitude'] as num?)?.toDouble(),
recipientLongitude: (json['recipient_longitude'] as num?)?.toDouble(),
);
}
}
class FloodPathStats {
final int successCount;
final int failureCount;
final int lastRoundTripTimeMs;
final DateTime? lastUsedAt;
const FloodPathStats({
required this.successCount,
required this.failureCount,
required this.lastRoundTripTimeMs,
required this.lastUsedAt,
});
const FloodPathStats.empty()
: successCount = 0,
failureCount = 0,
lastRoundTripTimeMs = 0,
lastUsedAt = null;
FloodPathStats copyWith({
int? successCount,
int? failureCount,
int? lastRoundTripTimeMs,
DateTime? lastUsedAt,
}) {
return FloodPathStats(
successCount: successCount ?? this.successCount,
failureCount: failureCount ?? this.failureCount,
lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs,
lastUsedAt: lastUsedAt ?? this.lastUsedAt,
);
}
Map<String, dynamic> toJson() {
return {
'success_count': successCount,
'failure_count': failureCount,
'last_round_trip_time_ms': lastRoundTripTimeMs,
'last_used_at': lastUsedAt?.toIso8601String(),
};
}
factory FloodPathStats.fromJson(Map<String, dynamic> json) {
return FloodPathStats(
successCount: json['success_count'] as int? ?? 0,
failureCount: json['failure_count'] as int? ?? 0,
lastRoundTripTimeMs: json['last_round_trip_time_ms'] as int? ?? 0,
lastUsedAt: DateTime.tryParse(json['last_used_at'] as String? ?? ''),
);
}
}
class ContactPathHistory {
final String contactPublicKeyHex;
final List<PathRecord> directPaths;
final FloodPathStats floodStats;
final int rotationIndex;
const ContactPathHistory({
required this.contactPublicKeyHex,
required this.directPaths,
required this.floodStats,
required this.rotationIndex,
});
const ContactPathHistory.empty(this.contactPublicKeyHex)
: directPaths = const <PathRecord>[],
floodStats = const FloodPathStats.empty(),
rotationIndex = 0;
ContactPathHistory copyWith({
List<PathRecord>? directPaths,
FloodPathStats? floodStats,
int? rotationIndex,
}) {
return ContactPathHistory(
contactPublicKeyHex: contactPublicKeyHex,
directPaths: directPaths ?? this.directPaths,
floodStats: floodStats ?? this.floodStats,
rotationIndex: rotationIndex ?? this.rotationIndex,
);
}
Map<String, dynamic> toJson() {
return {
'direct_paths': directPaths.map((record) => record.toJson()).toList(),
'flood_stats': floodStats.toJson(),
'rotation_index': rotationIndex,
};
}
List<PathRecord> get observedPaths => directPaths
.where((record) => record.source == PathRecordSource.observed)
.toList();
factory ContactPathHistory.fromJson(
String contactPublicKeyHex,
Map<String, dynamic> json,
) {
return ContactPathHistory(
contactPublicKeyHex: contactPublicKeyHex,
directPaths: (json['direct_paths'] as List<dynamic>? ?? const <dynamic>[])
.whereType<Map<String, dynamic>>()
.map(PathRecord.fromJson)
.toList(),
floodStats: json['flood_stats'] is Map<String, dynamic>
? FloodPathStats.fromJson(json['flood_stats'] as Map<String, dynamic>)
: const FloodPathStats.empty(),
rotationIndex: json['rotation_index'] as int? ?? 0,
);
}
}

View File

@@ -41,26 +41,22 @@ import '../utils/log_rx_route_decoder.dart';
class _DirectMessageRouteSession { class _DirectMessageRouteSession {
final PathSelection currentSelection; final PathSelection currentSelection;
final ParsedContactRoute? originalRoute; final ParsedContactRoute? originalRoute;
final bool usedManualOverride;
final bool routerFallbackAttempted; final bool routerFallbackAttempted;
const _DirectMessageRouteSession({ const _DirectMessageRouteSession({
required this.currentSelection, required this.currentSelection,
required this.originalRoute, required this.originalRoute,
required this.usedManualOverride,
required this.routerFallbackAttempted, required this.routerFallbackAttempted,
}); });
_DirectMessageRouteSession copyWith({ _DirectMessageRouteSession copyWith({
PathSelection? currentSelection, PathSelection? currentSelection,
ParsedContactRoute? originalRoute, ParsedContactRoute? originalRoute,
bool? usedManualOverride,
bool? routerFallbackAttempted, bool? routerFallbackAttempted,
}) { }) {
return _DirectMessageRouteSession( return _DirectMessageRouteSession(
currentSelection: currentSelection ?? this.currentSelection, currentSelection: currentSelection ?? this.currentSelection,
originalRoute: originalRoute ?? this.originalRoute, originalRoute: originalRoute ?? this.originalRoute,
usedManualOverride: usedManualOverride ?? this.usedManualOverride,
routerFallbackAttempted: routerFallbackAttempted:
routerFallbackAttempted ?? this.routerFallbackAttempted, routerFallbackAttempted ?? this.routerFallbackAttempted,
); );
@@ -199,9 +195,6 @@ class AppProvider with ChangeNotifier {
bool get isVoiceNoiseSuppressionEnabled => _isVoiceNoiseSuppressionEnabled; bool get isVoiceNoiseSuppressionEnabled => _isVoiceNoiseSuppressionEnabled;
double _messageFontScale = 1.0; double _messageFontScale = 1.0;
double get messageFontScale => _messageFontScale; double get messageFontScale => _messageFontScale;
bool _autoRouteRotationEnabled =
MessagingRoutePreferences.defaultAutoRouteRotationEnabled;
bool get autoRouteRotationEnabled => _autoRouteRotationEnabled;
bool _clearPathOnMaxRetry = bool _clearPathOnMaxRetry =
MessagingRoutePreferences.defaultClearPathOnMaxRetry; MessagingRoutePreferences.defaultClearPathOnMaxRetry;
bool get clearPathOnMaxRetry => _clearPathOnMaxRetry; bool get clearPathOnMaxRetry => _clearPathOnMaxRetry;
@@ -213,6 +206,7 @@ class AppProvider with ChangeNotifier {
const NearestRouterSelector(); const NearestRouterSelector();
final Map<String, _DirectMessageRouteSession> _directMessageRouteSessions = final Map<String, _DirectMessageRouteSession> _directMessageRouteSessions =
{}; {};
final Set<String> _pendingDeliveredRouteRefreshContacts = <String>{};
static const Duration _packetRetryDelay = Duration(milliseconds: 1200); static const Duration _packetRetryDelay = Duration(milliseconds: 1200);
static const Duration _mediaSwarmResponseWindow = Duration(seconds: 10); static const Duration _mediaSwarmResponseWindow = Duration(seconds: 10);
@@ -873,8 +867,7 @@ class AppProvider with ChangeNotifier {
Future<void> _loadMessagingRouteSettings() async { Future<void> _loadMessagingRouteSettings() async {
try { try {
_autoRouteRotationEnabled = await MessagingRoutePreferences.cleanupLegacySettings();
await MessagingRoutePreferences.getAutoRouteRotationEnabled();
_clearPathOnMaxRetry = _clearPathOnMaxRetry =
await MessagingRoutePreferences.getClearPathOnMaxRetry(); await MessagingRoutePreferences.getClearPathOnMaxRetry();
_nearestRelayFallbackEnabled = _nearestRelayFallbackEnabled =
@@ -885,16 +878,6 @@ class AppProvider with ChangeNotifier {
} }
} }
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 { Future<void> toggleClearPathOnMaxRetry(bool enabled) async {
try { try {
_clearPathOnMaxRetry = enabled; _clearPathOnMaxRetry = enabled;
@@ -1048,6 +1031,14 @@ class AppProvider with ChangeNotifier {
contact, contact,
devicePublicKey: devicePublicKey, devicePublicKey: devicePublicKey,
); );
final updatedContact =
contactsProvider.findContactByKey(contact.publicKey) ?? contact;
if (_pendingDeliveredRouteRefreshContacts.remove(
updatedContact.publicKeyHex,
)) {
messagesProvider.applyDeliveredMessageRouteFromContact(updatedContact);
}
}; };
// When all contacts are received // When all contacts are received
@@ -1254,18 +1245,6 @@ class AppProvider with ChangeNotifier {
enrichedMessage, enrichedMessage,
); );
final receivedPathBytes = receptionDetailsSnapshot?.pathBytes; final receivedPathBytes = receptionDetailsSnapshot?.pathBytes;
if (senderContact != null &&
enrichedMessage.isChannelMessage &&
receivedPathBytes != null &&
receivedPathBytes.isNotEmpty) {
unawaited(
_learnPathFromPublicMessage(
contact: senderContact,
pathBytes: receivedPathBytes,
),
);
}
// Estimate location for contacts without GPS using received path // Estimate location for contacts without GPS using received path
if (senderContact != null && if (senderContact != null &&
senderContact.displayLocation == null && senderContact.displayLocation == null &&
@@ -1685,6 +1664,7 @@ class AppProvider with ChangeNotifier {
// When a contact's routing path is updated in the mesh network // When a contact's routing path is updated in the mesh network
connectionProvider.onPathUpdated = (publicKey) { connectionProvider.onPathUpdated = (publicKey) {
_pendingDeliveredRouteRefreshContacts.add(_publicKeyHex(publicKey));
debugPrint( debugPrint(
'🔄 [AppProvider] Path updated for contact: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...', '🔄 [AppProvider] Path updated for contact: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...',
); );
@@ -1850,28 +1830,18 @@ class AppProvider with ChangeNotifier {
.getManualSelectionForContact(latestContact); .getManualSelectionForContact(latestContact);
final selection = final selection =
manualSelection ?? manualSelection ??
await _pathHistoryService.getSelectionForContact( await _pathHistoryService.getSelectionForContact(latestContact);
latestContact,
autoRouteRotationEnabled: _autoRouteRotationEnabled,
);
session = _DirectMessageRouteSession( session = _DirectMessageRouteSession(
currentSelection: selection, currentSelection: selection,
originalRoute: ContactRouteCodec.fromContact(latestContact), originalRoute: ContactRouteCodec.fromContact(latestContact),
usedManualOverride: manualSelection != null,
routerFallbackAttempted: false, routerFallbackAttempted: false,
); );
} }
if (!session.routerFallbackAttempted) { if (!session.routerFallbackAttempted) {
final currentSignature = session.currentSelection.hasDirectPath
? session.currentSelection.pathBytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
: null;
final selection = await _resolveDirectMessageSelectionForRetry( final selection = await _resolveDirectMessageSelectionForRetry(
latestContact, latestContact,
retryAttempt: retryAttempt, retryAttempt: retryAttempt,
currentSignature: currentSignature,
fallbackSelection: session.currentSelection, fallbackSelection: session.currentSelection,
); );
session = session.copyWith(currentSelection: selection); session = session.copyWith(currentSelection: selection);
@@ -1891,26 +1861,17 @@ class AppProvider with ChangeNotifier {
Future<PathSelection> _resolveDirectMessageSelectionForRetry( Future<PathSelection> _resolveDirectMessageSelectionForRetry(
Contact contact, { Contact contact, {
required int retryAttempt, required int retryAttempt,
required String? currentSignature,
required PathSelection fallbackSelection, required PathSelection fallbackSelection,
}) async { }) async {
if (retryAttempt == 2) { if (retryAttempt == 2) {
return PathSelection.flood(); return PathSelection.flood();
} }
if (retryAttempt >= 3) { if (retryAttempt < 2 &&
final historicalSelection = await _pathHistoryService !fallbackSelection.hasDirectPath &&
.getLastSuccessfulDirectSelection( contact.routeHasPath &&
contact, contact.routeHopCount > 0) {
excludeSignature: currentSignature, return _pathHistoryService.getSelectionForContact(contact);
senderLatitude: locationTrackingService.currentPosition?.latitude,
senderLongitude: locationTrackingService.currentPosition?.longitude,
recipientLatitude: contact.displayLocation?.latitude,
recipientLongitude: contact.displayLocation?.longitude,
);
if (historicalSelection != null) {
return historicalSelection;
}
} }
return fallbackSelection; return fallbackSelection;
@@ -2020,32 +1981,19 @@ class AppProvider with ChangeNotifier {
final latestContact = final latestContact =
contactsProvider.findContactByKey(contact.publicKey) ?? contact; contactsProvider.findContactByKey(contact.publicKey) ?? contact;
final manualSelection = await _pathHistoryService.getManualSelectionForContact(
latestContact,
);
final session = final session =
_directMessageRouteSessions[messageId] ?? _directMessageRouteSessions[messageId] ??
_DirectMessageRouteSession( _DirectMessageRouteSession(
currentSelection: currentSelection:
await _pathHistoryService.getManualSelectionForContact( manualSelection ??
latestContact, await _pathHistoryService.getSelectionForContact(latestContact),
) ??
await _pathHistoryService.getSelectionForContact(
latestContact,
autoRouteRotationEnabled: _autoRouteRotationEnabled,
),
originalRoute: ContactRouteCodec.fromContact(latestContact), originalRoute: ContactRouteCodec.fromContact(latestContact),
usedManualOverride:
await _pathHistoryService.getManualSelectionForContact(
latestContact,
) !=
null,
routerFallbackAttempted: false, routerFallbackAttempted: false,
); );
await _pathHistoryService.recordPathResult(
latestContact.publicKeyHex,
session.currentSelection,
success: false,
);
final repeater = _nearestRouterSelector.select( final repeater = _nearestRouterSelector.select(
senderPosition: locationTrackingService.currentPosition, senderPosition: locationTrackingService.currentPosition,
repeaters: contactsProvider.repeaters, repeaters: contactsProvider.repeaters,
@@ -2089,33 +2037,10 @@ class AppProvider with ChangeNotifier {
return; return;
} }
unawaited( if (session.currentSelection.usesFlood ||
() async { session.currentSelection.mode == PathSelectionMode.nearestRouter) {
await _pathHistoryService.recordPathResult( messagesProvider.queueDeliveredMessageRouteRefresh(messageId, contact);
contact.publicKeyHex, }
session.currentSelection,
success: true,
roundTripTimeMs: roundTripTimeMs,
senderLatitude: locationTrackingService.currentPosition?.latitude,
senderLongitude: locationTrackingService.currentPosition?.longitude,
recipientLatitude: contact.displayLocation?.latitude,
recipientLongitude: contact.displayLocation?.longitude,
);
if (!session.usedManualOverride) {
return;
}
if (session.currentSelection.mode == PathSelectionMode.directCurrent ||
session.currentSelection.mode ==
PathSelectionMode.directHistorical) {
await _pathHistoryService.setManualSelectionFor(
contact.publicKeyHex,
session.currentSelection,
);
return;
}
await _pathHistoryService.clearManualRouteFor(contact.publicKeyHex);
}(),
);
} }
Future<void> _handleDirectMessageFinalFailure({ Future<void> _handleDirectMessageFinalFailure({
@@ -2126,16 +2051,6 @@ class AppProvider with ChangeNotifier {
contactsProvider.findContactByKey(contact.publicKey) ?? contact; contactsProvider.findContactByKey(contact.publicKey) ?? contact;
final session = _directMessageRouteSessions.remove(messageId); final session = _directMessageRouteSessions.remove(messageId);
if (session != null) { if (session != null) {
await _pathHistoryService.recordPathResult(
latestContact.publicKeyHex,
session.currentSelection,
success: false,
);
if (session.usedManualOverride) {
await _pathHistoryService.clearManualRouteFor(
latestContact.publicKeyHex,
);
}
if (session.routerFallbackAttempted) { if (session.routerFallbackAttempted) {
await _restoreRouteOnDevice(latestContact, session.originalRoute); await _restoreRouteOnDevice(latestContact, session.originalRoute);
} }
@@ -2157,6 +2072,12 @@ class AppProvider with ChangeNotifier {
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
} }
String _publicKeyHex(Uint8List publicKey) {
return publicKey
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
}
/// Estimate contact location from the received message path. /// Estimate contact location from the received message path.
/// ///
/// When we receive a message, the path bytes describe how it traveled: /// When we receive a message, the path bytes describe how it traveled:
@@ -2207,22 +2128,6 @@ class AppProvider with ChangeNotifier {
); );
} }
Future<void> _learnPathFromPublicMessage({
required Contact contact,
required List<int> pathBytes,
}) async {
final preferred = await RouteHashPreferences.getHashSize();
final inferredHashSize = _inferReceivedPathHashSize(
pathBytes,
preferredHashSize: preferred,
);
await _pathHistoryService.recordReceivedBytePath(
contact.publicKeyHex,
pathBytes,
inferredHashSize,
);
}
Future<void> _retainAdvertRxPath(Uint8List publicKey) async { Future<void> _retainAdvertRxPath(Uint8List publicKey) async {
final decoded = _findBestMatchingAdvertRxRoute(publicKey); final decoded = _findBestMatchingAdvertRxRoute(publicKey);
if (decoded == null || decoded.pathBytes.isEmpty) { if (decoded == null || decoded.pathBytes.isEmpty) {
@@ -2248,11 +2153,6 @@ class AppProvider with ChangeNotifier {
paddedPathBytes: parsedRoute.paddedPathBytes, paddedPathBytes: parsedRoute.paddedPathBytes,
devicePublicKey: connectionProvider.deviceInfo.publicKey, devicePublicKey: connectionProvider.deviceInfo.publicKey,
); );
await _pathHistoryService.recordReceivedBytePath(
publicKey.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(),
decoded.pathBytes,
decoded.hashSize,
);
} }
/// Handle pushAdvert (0x80) — matches the official MeshCore app flow: /// Handle pushAdvert (0x80) — matches the official MeshCore app flow:
@@ -2593,22 +2493,6 @@ class AppProvider with ChangeNotifier {
} }
} }
int _inferReceivedPathHashSize(
List<int> pathBytes, {
required int preferredHashSize,
}) {
final preferred = preferredHashSize;
final candidates = {preferred, 3, 2, 1}.toList();
for (final candidate in candidates) {
if (candidate >= 1 &&
candidate <= 3 &&
pathBytes.length % candidate == 0) {
return candidate;
}
}
return 1;
}
/// Initialize the app (load contacts, sync time, etc.) /// Initialize the app (load contacts, sync time, etc.)
Future<void> initialize() async { Future<void> initialize() async {
if (!connectionProvider.deviceInfo.isConnected) return; if (!connectionProvider.deviceInfo.isConnected) return;

View File

@@ -24,6 +24,7 @@ typedef DisplayMessageEntry = ({Message message, int occurrenceCount});
class MessagesProvider with ChangeNotifier { class MessagesProvider with ChangeNotifier {
static const Duration _channelEchoWarningDelay = Duration(seconds: 12); static const Duration _channelEchoWarningDelay = Duration(seconds: 12);
static const Duration _receivedDuplicateWindow = Duration(seconds: 5); static const Duration _receivedDuplicateWindow = Duration(seconds: 5);
static const Duration _deliveredRouteRefreshWindow = Duration(seconds: 30);
final List<Message> _messages = []; final List<Message> _messages = [];
final Map<String, SarMarker> _sarMarkers = {}; final Map<String, SarMarker> _sarMarkers = {};
@@ -38,6 +39,9 @@ class MessagesProvider with ChangeNotifier {
final Map<String, MessageReceptionDetails> _messageReceptionDetails = {}; final Map<String, MessageReceptionDetails> _messageReceptionDetails = {};
final Map<String, MessageTransferDetails> _messageTransferDetails = {}; final Map<String, MessageTransferDetails> _messageTransferDetails = {};
final Map<String, MessageRouteMetadata> _messageRouteMetadata = {}; final Map<String, MessageRouteMetadata> _messageRouteMetadata = {};
final Map<String, List<_PendingDeliveredRouteRefresh>>
_pendingDeliveredRouteRefreshByContact =
<String, List<_PendingDeliveredRouteRefresh>>{};
String? _storageNamespace; String? _storageNamespace;
// Track pending sent messages by expected ACK/TAG // Track pending sent messages by expected ACK/TAG
@@ -199,6 +203,83 @@ class MessagesProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void queueDeliveredMessageRouteRefresh(String messageId, Contact contact) {
if (messageId.isEmpty || contact.publicKeyHex.isEmpty) {
return;
}
_prunePendingDeliveredRouteRefresh();
final queue = _pendingDeliveredRouteRefreshByContact.putIfAbsent(
contact.publicKeyHex,
() => <_PendingDeliveredRouteRefresh>[],
);
queue.removeWhere((entry) => entry.messageId == messageId);
queue.add(
_PendingDeliveredRouteRefresh(
messageId: messageId,
queuedAt: DateTime.now(),
),
);
}
bool applyDeliveredMessageRouteFromContact(Contact contact) {
if (!contact.routeHasPath ||
contact.routeHopCount <= 0 ||
contact.publicKeyHex.isEmpty) {
return false;
}
_prunePendingDeliveredRouteRefresh();
final queue = _pendingDeliveredRouteRefreshByContact[contact.publicKeyHex];
if (queue == null || queue.isEmpty) {
return false;
}
while (queue.isNotEmpty) {
final pending = queue.removeAt(0);
final index = _messages.indexWhere(
(message) => message.id == pending.messageId,
);
if (index == -1) {
continue;
}
final message = _messages[index];
if (!message.isContactMessage ||
message.deliveryStatus != MessageDeliveryStatus.delivered) {
continue;
}
final existingMetadata = _messageRouteMetadata[pending.messageId];
_messageRouteMetadata[pending.messageId] = MessageRouteMetadata(
mode: PathSelectionMode.directCurrent,
routerFallbackAttempted:
existingMetadata?.routerFallbackAttempted ?? false,
canonicalPath: contact.routeCanonicalText.isEmpty
? null
: contact.routeCanonicalText,
hopCount: contact.routeHopCount,
);
_messages[index] = message.copyWith(
pathLen: contact.routeHopCount,
usedFloodFallback: false,
);
if (queue.isEmpty) {
_pendingDeliveredRouteRefreshByContact.remove(contact.publicKeyHex);
}
_persistMessages();
notifyListeners();
return true;
}
if (queue.isEmpty) {
_pendingDeliveredRouteRefreshByContact.remove(contact.publicKeyHex);
}
return false;
}
/// Set localizations for notifications /// Set localizations for notifications
void setLocalizations(AppLocalizations localizations) { void setLocalizations(AppLocalizations localizations) {
_localizations = localizations; _localizations = localizations;
@@ -2555,6 +2636,7 @@ class MessagesProvider with ChangeNotifier {
_pendingSentMessages.remove(message.expectedAckTag); _pendingSentMessages.remove(message.expectedAckTag);
} }
_clearAckHistoryForMessage(messageId); _clearAckHistoryForMessage(messageId);
_removePendingDeliveredRouteRefresh(messageId);
// Clear retry tracking // Clear retry tracking
_retryManager.clearRetry(messageId); _retryManager.clearRetry(messageId);
@@ -2595,6 +2677,7 @@ class MessagesProvider with ChangeNotifier {
_pendingSentMessages.remove(message.expectedAckTag); _pendingSentMessages.remove(message.expectedAckTag);
} }
_clearAckHistoryForMessage(messageId); _clearAckHistoryForMessage(messageId);
_removePendingDeliveredRouteRefresh(messageId);
_retryManager.clearRetry(messageId); _retryManager.clearRetry(messageId);
_messageRouteMetadata.remove(messageId); _messageRouteMetadata.remove(messageId);
onManualRetryPreparedCallback?.call(messageId); onManualRetryPreparedCallback?.call(messageId);
@@ -2767,4 +2850,51 @@ class MessagesProvider with ChangeNotifier {
} }
} }
} }
void _prunePendingDeliveredRouteRefresh() {
if (_pendingDeliveredRouteRefreshByContact.isEmpty) {
return;
}
final cutoff = DateTime.now().subtract(_deliveredRouteRefreshWindow);
final emptyKeys = <String>[];
for (final entry in _pendingDeliveredRouteRefreshByContact.entries) {
entry.value.removeWhere(
(pending) => pending.queuedAt.isBefore(cutoff),
);
if (entry.value.isEmpty) {
emptyKeys.add(entry.key);
}
}
for (final key in emptyKeys) {
_pendingDeliveredRouteRefreshByContact.remove(key);
}
}
void _removePendingDeliveredRouteRefresh(String messageId) {
if (_pendingDeliveredRouteRefreshByContact.isEmpty) {
return;
}
final emptyKeys = <String>[];
for (final entry in _pendingDeliveredRouteRefreshByContact.entries) {
entry.value.removeWhere((pending) => pending.messageId == messageId);
if (entry.value.isEmpty) {
emptyKeys.add(entry.key);
}
}
for (final key in emptyKeys) {
_pendingDeliveredRouteRefreshByContact.remove(key);
}
}
}
class _PendingDeliveredRouteRefresh {
final String messageId;
final DateTime queuedAt;
const _PendingDeliveredRouteRefresh({
required this.messageId,
required this.queuedAt,
});
} }

View File

@@ -1486,19 +1486,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: _showRouteHashSizeDialog, onTap: _showRouteHashSizeDialog,
), ),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: Icon(Icons.swap_horiz),
title: Text(AppLocalizations.of(context)!.autoRouteRotation),
subtitle: const Text(
'Rotate between best known direct paths and flood mode for room/contact sends',
),
value: appProvider.autoRouteRotationEnabled,
onChanged: (value) async {
await appProvider.toggleAutoRouteRotationEnabled(value);
},
),
),
Consumer<AppProvider>( Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile( builder: (context, appProvider, child) => SwitchListTile(
secondary: Icon(Icons.route), secondary: Icon(Icons.route),

View File

@@ -23,7 +23,6 @@ class AppConfigSnapshotService {
voiceEchoCancellationEnabled: appProvider.isVoiceEchoCancellationEnabled, voiceEchoCancellationEnabled: appProvider.isVoiceEchoCancellationEnabled,
voiceNoiseSuppressionEnabled: appProvider.isVoiceNoiseSuppressionEnabled, voiceNoiseSuppressionEnabled: appProvider.isVoiceNoiseSuppressionEnabled,
messageFontScale: appProvider.messageFontScale, messageFontScale: appProvider.messageFontScale,
autoRouteRotationEnabled: appProvider.autoRouteRotationEnabled,
clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry, clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry,
nearestRelayFallbackEnabled: appProvider.nearestRelayFallbackEnabled, nearestRelayFallbackEnabled: appProvider.nearestRelayFallbackEnabled,
voiceBitrate: await VoiceBitratePreferences.getBitrate(), voiceBitrate: await VoiceBitratePreferences.getBitrate(),
@@ -98,11 +97,6 @@ class AppConfigSnapshotService {
if (section.messageFontScale != null) { if (section.messageFontScale != null) {
await appProvider.setMessageFontScale(section.messageFontScale!); await appProvider.setMessageFontScale(section.messageFontScale!);
} }
if (section.autoRouteRotationEnabled != null) {
await appProvider.toggleAutoRouteRotationEnabled(
section.autoRouteRotationEnabled!,
);
}
if (section.clearPathOnMaxRetry != null) { if (section.clearPathOnMaxRetry != null) {
await appProvider.toggleClearPathOnMaxRetry(section.clearPathOnMaxRetry!); await appProvider.toggleClearPathOnMaxRetry(section.clearPathOnMaxRetry!);
} }

View File

@@ -2,30 +2,20 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'profiles_feature_service.dart'; import 'profiles_feature_service.dart';
class MessagingRoutePreferences { class MessagingRoutePreferences {
static const bool defaultAutoRouteRotationEnabled = false;
static const bool defaultClearPathOnMaxRetry = false; static const bool defaultClearPathOnMaxRetry = false;
static const bool defaultNearestRelayFallbackEnabled = true; static const bool defaultNearestRelayFallbackEnabled = true;
static const String _autoRouteRotationKey = static const String _legacyAutoRouteRotationKey =
'messaging_auto_route_rotation_enabled'; 'messaging_auto_route_rotation_enabled';
static const String _clearPathOnMaxRetryKey = static const String _clearPathOnMaxRetryKey =
'messaging_clear_path_on_max_retry'; 'messaging_clear_path_on_max_retry';
static const String _nearestRelayFallbackKey = static const String _nearestRelayFallbackKey =
'messaging_nearest_relay_fallback_enabled'; 'messaging_nearest_relay_fallback_enabled';
static Future<bool> getAutoRouteRotationEnabled() async { static Future<void> cleanupLegacySettings() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
return prefs.getBool( await prefs.remove(
ProfileStorageScope.scopedKey(_autoRouteRotationKey), ProfileStorageScope.scopedKey(_legacyAutoRouteRotationKey),
) ??
defaultAutoRouteRotationEnabled;
}
static Future<void> setAutoRouteRotationEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(
ProfileStorageScope.scopedKey(_autoRouteRotationKey),
enabled,
); );
} }

View File

@@ -1,13 +1,10 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/path_history.dart';
import '../models/path_selection.dart'; import '../models/path_selection.dart';
import '../utils/log_rx_route_decoder.dart';
class _ManualPathSelectionRecord { class _ManualPathSelectionRecord {
final List<int> pathBytes; final List<int> pathBytes;
@@ -46,46 +43,19 @@ class _ManualPathSelectionRecord {
} }
class PathHistoryService { class PathHistoryService {
static const String _storageKey = 'contact_path_history_v2'; static const String _legacyStorageKey = 'contact_path_history_v2';
static const String _manualRouteStorageKey = static const String _manualRouteStorageKey =
'contact_manual_path_overrides_v1'; 'contact_manual_path_overrides_v1';
static const int _maxDirectPaths = 20;
static const int _topRotationCount = 3;
final Map<String, ContactPathHistory> _cache = {};
final Map<String, _ManualPathSelectionRecord> _manualSelections = {}; final Map<String, _ManualPathSelectionRecord> _manualSelections = {};
bool _isLoaded = false; bool _isLoaded = false;
Future<void> initialize() async { Future<void> initialize() async {
if (_isLoaded) return; if (_isLoaded) return;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_storageKey); await prefs.remove(_legacyStorageKey);
final manualRaw = prefs.getString(_manualRouteStorageKey);
if (raw == null || raw.isEmpty) {
if (manualRaw == null || manualRaw.isEmpty) {
_isLoaded = true;
return;
}
}
try { final manualRaw = prefs.getString(_manualRouteStorageKey);
if (raw != null && raw.isNotEmpty) {
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
for (final entry in decoded.entries) {
final value = entry.value;
if (value is Map<String, dynamic>) {
_cache[entry.key] = ContactPathHistory.fromJson(
entry.key,
value,
);
}
}
}
}
} catch (error) {
debugPrint('⚠️ [PathHistoryService] Failed to load history: $error');
}
try { try {
if (manualRaw != null && manualRaw.isNotEmpty) { if (manualRaw != null && manualRaw.isNotEmpty) {
final decoded = jsonDecode(manualRaw); final decoded = jsonDecode(manualRaw);
@@ -104,220 +74,30 @@ class PathHistoryService {
'⚠️ [PathHistoryService] Failed to load manual routes: $error', '⚠️ [PathHistoryService] Failed to load manual routes: $error',
); );
} }
_isLoaded = true; _isLoaded = true;
} }
Future<void> recordReceivedBytePath( Future<PathSelection> getSelectionForContact(Contact contact) async {
String contactPublicKeyHex,
List<int> pathBytes,
int hashSize,
) async {
await initialize();
if (pathBytes.isEmpty) {
return;
}
if (hashSize < 1 || hashSize > 3) {
return;
}
if (pathBytes.length % hashSize != 0) {
return;
}
final normalizedPathBytes = LogRxRouteDecoder.reverseHopBytes(
pathBytes,
hashSize: hashSize,
);
final history = _historyFor(contactPublicKeyHex);
final signature = normalizedPathBytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
final existing = _findDirectPath(history.directPaths, signature);
final updated = PathRecord(
pathBytes: normalizedPathBytes,
hopCount: normalizedPathBytes.length ~/ hashSize,
hashSize: hashSize,
source: PathRecordSource.observed,
successCount: existing?.successCount ?? 0,
failureCount: existing?.failureCount ?? 0,
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
lastUsedAt: DateTime.now(),
lastSucceededAt: existing?.lastSucceededAt,
senderLatitude: existing?.senderLatitude,
senderLongitude: existing?.senderLongitude,
recipientLatitude: existing?.recipientLatitude,
recipientLongitude: existing?.recipientLongitude,
);
await _saveHistory(
contactPublicKeyHex,
history.copyWith(
directPaths: _upsertDirectPath(history.directPaths, updated),
),
);
}
Future<PathSelection> getSelectionForContact(
Contact contact, {
required bool autoRouteRotationEnabled,
}) async {
await initialize(); await initialize();
final manualSelection = _manualSelections[contact.publicKeyHex]; final manualSelection = _manualSelections[contact.publicKeyHex];
if (manualSelection != null) { if (manualSelection != null) {
return manualSelection.toSelection(); return manualSelection.toSelection();
} }
if (!autoRouteRotationEnabled) { final route = ContactRouteCodec.fromContact(contact);
if (route == null) {
return PathSelection.flood(); return PathSelection.flood();
} }
final history = _historyFor(contact.publicKeyHex);
final ranked = List<PathRecord>.from(history.directPaths)
..sort(_comparePathRecords);
final topPaths = ranked.take(_topRotationCount).toList();
if (topPaths.isEmpty) {
final nextFloodHistory = history.copyWith(
rotationIndex: history.rotationIndex + 1,
);
await _saveHistory(contact.publicKeyHex, nextFloodHistory);
return PathSelection.flood();
}
final selections =
topPaths
.map(
(record) => PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList(record.pathBytes),
hopCount: record.hopCount,
hashSize: record.hashSize,
),
)
.toList()
..add(PathSelection.flood());
final index = history.rotationIndex % selections.length;
final updatedHistory = history.copyWith(
rotationIndex: history.rotationIndex + 1,
);
await _saveHistory(contact.publicKeyHex, updatedHistory);
return selections[index];
}
Future<void> recordPathResult(
String contactPublicKeyHex,
PathSelection selection, {
required bool success,
int? roundTripTimeMs,
double? senderLatitude,
double? senderLongitude,
double? recipientLatitude,
double? recipientLongitude,
}) async {
await initialize();
final history = _historyFor(contactPublicKeyHex);
if (selection.usesFlood) {
final current = history.floodStats;
await _saveHistory(
contactPublicKeyHex,
history.copyWith(
floodStats: current.copyWith(
successCount: current.successCount + (success ? 1 : 0),
failureCount: current.failureCount + (success ? 0 : 1),
lastRoundTripTimeMs: success
? (roundTripTimeMs ?? current.lastRoundTripTimeMs)
: current.lastRoundTripTimeMs,
lastUsedAt: DateTime.now(),
),
),
);
return;
}
final signature = _signature(selection.pathBytes);
final existing = _findDirectPath(history.directPaths, signature);
final updated = PathRecord(
pathBytes: selection.pathBytes.toList(),
hopCount: selection.hopCount,
hashSize: selection.hashSize,
source: success
? PathRecordSource.learned
: existing?.source ?? PathRecordSource.learned,
successCount: (existing?.successCount ?? 0) + (success ? 1 : 0),
failureCount: (existing?.failureCount ?? 0) + (success ? 0 : 1),
lastRoundTripTimeMs: success
? (roundTripTimeMs ?? existing?.lastRoundTripTimeMs ?? 0)
: (existing?.lastRoundTripTimeMs ?? 0),
lastUsedAt: DateTime.now(),
lastSucceededAt: success ? DateTime.now() : existing?.lastSucceededAt,
senderLatitude: success ? senderLatitude : existing?.senderLatitude,
senderLongitude: success ? senderLongitude : existing?.senderLongitude,
recipientLatitude:
success ? recipientLatitude : existing?.recipientLatitude,
recipientLongitude:
success ? recipientLongitude : existing?.recipientLongitude,
);
await _saveHistory(
contactPublicKeyHex,
history.copyWith(
directPaths: _upsertDirectPath(history.directPaths, updated),
),
);
}
Future<PathSelection?> getLastSuccessfulDirectSelection(
Contact contact, {
String? excludeSignature,
double? senderLatitude,
double? senderLongitude,
double? recipientLatitude,
double? recipientLongitude,
}) async {
await initialize();
final history = _historyFor(contact.publicKeyHex);
final ranked = history.directPaths
.where(
(record) =>
record.successCount > 0 &&
record.lastSucceededAt != null &&
record.signature != excludeSignature,
)
.toList()
..sort((a, b) {
final locationCompare = _compareLocationFit(
a,
b,
senderLatitude: senderLatitude,
senderLongitude: senderLongitude,
recipientLatitude: recipientLatitude,
recipientLongitude: recipientLongitude,
);
if (locationCompare != 0) return locationCompare;
final succeededCompare = b.lastSucceededAt!.compareTo(
a.lastSucceededAt!,
);
if (succeededCompare != 0) return succeededCompare;
return _comparePathRecords(a, b);
});
if (ranked.isEmpty) {
return null;
}
final record = ranked.first;
return PathSelection( return PathSelection(
mode: PathSelectionMode.directHistorical, mode: PathSelectionMode.directCurrent,
pathBytes: Uint8List.fromList(record.pathBytes), pathBytes: Uint8List.fromList(route.pathBytes),
hopCount: record.hopCount, hopCount: route.hopCount,
hashSize: record.hashSize, hashSize: route.hashSize,
); );
} }
ContactPathHistory historyFor(String contactPublicKeyHex) {
return _cache[contactPublicKeyHex] ??
ContactPathHistory.empty(contactPublicKeyHex);
}
Future<void> setManualRouteForContact( Future<void> setManualRouteForContact(
Contact contact, Contact contact,
ParsedContactRoute route, ParsedContactRoute route,
@@ -343,7 +123,7 @@ class PathHistoryService {
hopCount: selection.hopCount, hopCount: selection.hopCount,
hashSize: selection.hashSize, hashSize: selection.hashSize,
); );
await _persistState(); await _persistManualSelections();
} }
Future<PathSelection?> getManualSelectionForContact(Contact contact) async { Future<PathSelection?> getManualSelectionForContact(Contact contact) async {
@@ -354,149 +134,15 @@ class PathHistoryService {
Future<void> clearManualRouteFor(String contactPublicKeyHex) async { Future<void> clearManualRouteFor(String contactPublicKeyHex) async {
await initialize(); await initialize();
_manualSelections.remove(contactPublicKeyHex); _manualSelections.remove(contactPublicKeyHex);
await _persistState(); await _persistManualSelections();
} }
Future<void> clearHistoryFor(String contactPublicKeyHex) async { Future<void> _persistManualSelections() async {
await initialize();
_cache.remove(contactPublicKeyHex);
await _persistState();
}
Future<void> clearHistoryForContact(Contact contact) async {
await clearHistoryFor(contact.publicKeyHex);
}
ContactPathHistory _historyFor(String contactPublicKeyHex) {
return _cache.putIfAbsent(
contactPublicKeyHex,
() => ContactPathHistory.empty(contactPublicKeyHex),
);
}
Future<void> _saveHistory(
String contactPublicKeyHex,
ContactPathHistory history,
) async {
_cache[contactPublicKeyHex] = history;
await _persistState();
}
Future<void> _persistState() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final payload = <String, dynamic>{};
for (final entry in _cache.entries) {
payload[entry.key] = entry.value.toJson();
}
final manualPayload = <String, dynamic>{}; final manualPayload = <String, dynamic>{};
for (final entry in _manualSelections.entries) { for (final entry in _manualSelections.entries) {
manualPayload[entry.key] = entry.value.toJson(); manualPayload[entry.key] = entry.value.toJson();
} }
await prefs.setString(_storageKey, jsonEncode(payload));
await prefs.setString(_manualRouteStorageKey, jsonEncode(manualPayload)); await prefs.setString(_manualRouteStorageKey, jsonEncode(manualPayload));
} }
List<PathRecord> _upsertDirectPath(
List<PathRecord> existing,
PathRecord updatedRecord,
) {
final updated = List<PathRecord>.from(existing)
..removeWhere((record) => record.signature == updatedRecord.signature)
..insert(0, updatedRecord);
if (updated.length > _maxDirectPaths) {
return updated.take(_maxDirectPaths).toList();
}
return updated;
}
int _comparePathRecords(PathRecord a, PathRecord b) {
final successRateCompare = b.successRate.compareTo(a.successRate);
if (successRateCompare != 0) return successRateCompare;
final successCountCompare = b.successCount.compareTo(a.successCount);
if (successCountCompare != 0) return successCountCompare;
final aRtt = a.lastRoundTripTimeMs == 0 ? 1 << 30 : a.lastRoundTripTimeMs;
final bRtt = b.lastRoundTripTimeMs == 0 ? 1 << 30 : b.lastRoundTripTimeMs;
final rttCompare = aRtt.compareTo(bRtt);
if (rttCompare != 0) return rttCompare;
return b.lastUsedAt.compareTo(a.lastUsedAt);
}
PathRecord? _findDirectPath(List<PathRecord> records, String signature) {
for (final record in records) {
if (record.signature == signature) {
return record;
}
}
return null;
}
String _signature(Uint8List bytes) =>
bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
int _compareLocationFit(
PathRecord a,
PathRecord b, {
required double? senderLatitude,
required double? senderLongitude,
required double? recipientLatitude,
required double? recipientLongitude,
}) {
final aDistance = _locationDistanceScore(
a,
senderLatitude: senderLatitude,
senderLongitude: senderLongitude,
recipientLatitude: recipientLatitude,
recipientLongitude: recipientLongitude,
);
final bDistance = _locationDistanceScore(
b,
senderLatitude: senderLatitude,
senderLongitude: senderLongitude,
recipientLatitude: recipientLatitude,
recipientLongitude: recipientLongitude,
);
return aDistance.compareTo(bDistance);
}
double _locationDistanceScore(
PathRecord record, {
required double? senderLatitude,
required double? senderLongitude,
required double? recipientLatitude,
required double? recipientLongitude,
}) {
var total = 0.0;
var matched = false;
if (senderLatitude != null &&
senderLongitude != null &&
record.senderLatitude != null &&
record.senderLongitude != null) {
matched = true;
total += Geolocator.distanceBetween(
senderLatitude,
senderLongitude,
record.senderLatitude!,
record.senderLongitude!,
);
}
if (recipientLatitude != null &&
recipientLongitude != null &&
record.recipientLatitude != null &&
record.recipientLongitude != null) {
matched = true;
total += Geolocator.distanceBetween(
recipientLatitude,
recipientLongitude,
record.recipientLatitude!,
record.recipientLongitude!,
);
}
return matched ? total : double.infinity;
}
} }

View File

@@ -6,11 +6,9 @@ import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../models/contact.dart'; import '../../models/contact.dart';
import '../../models/path_history.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../providers/app_provider.dart'; import '../../providers/app_provider.dart';
import '../../providers/connection_provider.dart'; import '../../providers/connection_provider.dart';
import '../../services/path_history_service.dart';
import '../../services/relay_candidate_sorter.dart'; import '../../services/relay_candidate_sorter.dart';
import '../../services/route_hash_preferences.dart'; import '../../services/route_hash_preferences.dart';
@@ -72,7 +70,6 @@ class ContactRouteDialog extends StatefulWidget {
class _ContactRouteDialogState extends State<ContactRouteDialog> { class _ContactRouteDialogState extends State<ContactRouteDialog> {
late final TextEditingController _controller; late final TextEditingController _controller;
late final TextEditingController _relaySearchController; late final TextEditingController _relaySearchController;
final PathHistoryService _pathHistoryService = PathHistoryService();
final RelayCandidateSorter _relayCandidateSorter = final RelayCandidateSorter _relayCandidateSorter =
const RelayCandidateSorter(); const RelayCandidateSorter();
int _selectedHashSize = RouteHashPreferences.defaultHashSize; int _selectedHashSize = RouteHashPreferences.defaultHashSize;
@@ -80,7 +77,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
String? _errorText; String? _errorText;
bool _showRoutingInfo = false; bool _showRoutingInfo = false;
List<Contact> _selectedMapHops = const []; List<Contact> _selectedMapHops = const [];
ContactPathHistory? _pathHistory;
@override @override
void initState() { void initState() {
@@ -91,7 +87,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
_relaySearchController = TextEditingController(); _relaySearchController = TextEditingController();
_controller.addListener(_reparse); _controller.addListener(_reparse);
_loadHashSizePreference(); _loadHashSizePreference();
_loadPathHistory();
_reparse(); _reparse();
} }
@@ -182,16 +177,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
_reparse(); _reparse();
} }
Future<void> _loadPathHistory() async {
await _pathHistoryService.initialize();
if (!mounted) return;
setState(() {
_pathHistory = _pathHistoryService.historyFor(
widget.contact.publicKeyHex,
);
});
}
String _tokenFor(Contact contact, int hashSize) { String _tokenFor(Contact contact, int hashSize) {
final hex = contact.publicKeyHex.toUpperCase(); final hex = contact.publicKeyHex.toUpperCase();
final length = hashSize * 2; final length = hashSize * 2;
@@ -237,21 +222,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
}); });
} }
void _applyHistoryRecord(PathRecord record) {
final canonicalText = _canonicalRouteFromBytes(
record.pathBytes,
hashSize: record.hashSize,
);
setState(() {
_controller.text = canonicalText;
_controller.selection = TextSelection.fromPosition(
TextPosition(offset: _controller.text.length),
);
_errorText = null;
});
_reparse();
}
LatLng? _resolveLastHopLocation() { LatLng? _resolveLastHopLocation() {
if (_selectedMapHops.isNotEmpty) { if (_selectedMapHops.isNotEmpty) {
return _selectedMapHops.last.displayLocation == null return _selectedMapHops.last.displayLocation == null
@@ -302,86 +272,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
); );
} }
String _canonicalRouteFromBytes(
List<int> pathBytes, {
required int hashSize,
}) {
final hops = <String>[];
for (var i = 0; i < pathBytes.length; i += hashSize) {
final hop = pathBytes.sublist(i, i + hashSize);
hops.add(
hop
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase(),
);
}
return hops.join(',');
}
String _historySubtitle(PathRecord record) {
final attempts = record.successCount + record.failureCount;
final lastSeen = MaterialLocalizations.of(
context,
).formatShortDate(record.lastUsedAt);
final sourceLabel = switch (record.source) {
PathRecordSource.observed => 'Observed on mesh',
PathRecordSource.learned => 'Learned route',
};
final successRate = attempts == 0
? 'No send stats yet'
: '${(record.successRate * 100).round()}% success over $attempts send${attempts == 1 ? '' : 's'}';
final latency = record.lastRoundTripTimeMs > 0
? '${record.lastRoundTripTimeMs} ms'
: '';
return '$sourceLabel$successRate • Last used $lastSeen$latency';
}
Widget _buildHistoryRecordTile(PathRecord record, {String? title}) {
final canonicalText = _canonicalRouteFromBytes(
record.pathBytes,
hashSize: record.hashSize,
);
return Card(
margin: EdgeInsets.zero,
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
leading: title == null ? null : const Icon(Icons.alt_route),
title: title == null
? Text(
canonicalText,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 6),
Text(
canonicalText,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
),
],
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(_historySubtitle(record)),
),
trailing: FilledButton.tonal(
onPressed: () => _applyHistoryRecord(record),
child: Text(AppLocalizations.of(context)!.use),
),
),
);
}
Widget _buildPreviewSection() { Widget _buildPreviewSection() {
final previewRoute = _effectiveRoute; final previewRoute = _effectiveRoute;
if (previewRoute == null) { if (previewRoute == null) {
@@ -676,7 +566,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
_showRoutingInfo = !_showRoutingInfo; _showRoutingInfo = !_showRoutingInfo;
}); });
}, },
autoRouteRotationEnabled: appProvider.autoRouteRotationEnabled,
nearestRelayFallbackEnabled: appProvider.nearestRelayFallbackEnabled, nearestRelayFallbackEnabled: appProvider.nearestRelayFallbackEnabled,
clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry, clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry,
), ),
@@ -685,79 +574,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
); );
} }
Widget _buildHistoryTab() {
final records = List<PathRecord>.from(_pathHistory?.directPaths ?? const [])
..sort((a, b) => b.lastUsedAt.compareTo(a.lastUsedAt));
if (records.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'No historical paths for this contact yet.',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
),
);
}
PathRecord? observedRecord;
for (final record in records) {
if (record.source == PathRecordSource.observed) {
observedRecord = record;
break;
}
}
final remainingRecords = observedRecord == null
? records
: records
.where((record) => !identical(record, observedRecord))
.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () async {
await _pathHistoryService.clearHistoryForContact(widget.contact);
if (!mounted) return;
setState(() {
_pathHistory = _pathHistoryService.historyFor(
widget.contact.publicKeyHex,
);
});
},
child: const Text('Clear history'),
),
),
const SizedBox(height: 8),
if (observedRecord != null) ...[
_buildHistoryRecordTile(observedRecord, title: AppLocalizations.of(context)!.observedMeshRoute),
const SizedBox(height: 16),
],
if (remainingRecords.isEmpty)
Text(
observedRecord == null
? 'No additional route history yet.'
: 'Observed routes you start using will continue to build history here.',
style: Theme.of(context).textTheme.bodyMedium,
)
else
ListView.separated(
itemCount: remainingRecords.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
return _buildHistoryRecordTile(remainingRecords[index]);
},
),
],
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final effectiveRoute = _effectiveRoute; final effectiveRoute = _effectiveRoute;
@@ -802,14 +618,13 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
]; ];
return DefaultTabController( return DefaultTabController(
length: 3, length: 2,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text('Set Path for ${widget.contact.displayName}'), title: Text('Set Path for ${widget.contact.displayName}'),
bottom: const TabBar( bottom: const TabBar(
tabs: [ tabs: [
Tab(text: 'Build'), Tab(text: 'Build'),
Tab(text: 'History'),
Tab(text: 'Info'), Tab(text: 'Info'),
], ],
), ),
@@ -827,12 +642,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
const SizedBox(height: 24), const SizedBox(height: 24),
], ],
), ),
ListView(
children: [
_buildHistoryTab(),
const SizedBox(height: 24),
],
),
_buildInfoTab( _buildInfoTab(
appProvider: appProvider, appProvider: appProvider,
routeCandidates: routeCandidates, routeCandidates: routeCandidates,
@@ -912,14 +721,12 @@ class _RouteMarkerDot extends StatelessWidget {
class _AutomationRoutingInfo extends StatelessWidget { class _AutomationRoutingInfo extends StatelessWidget {
final bool isExpanded; final bool isExpanded;
final VoidCallback onToggle; final VoidCallback onToggle;
final bool autoRouteRotationEnabled;
final bool nearestRelayFallbackEnabled; final bool nearestRelayFallbackEnabled;
final bool clearPathOnMaxRetry; final bool clearPathOnMaxRetry;
const _AutomationRoutingInfo({ const _AutomationRoutingInfo({
required this.isExpanded, required this.isExpanded,
required this.onToggle, required this.onToggle,
required this.autoRouteRotationEnabled,
required this.nearestRelayFallbackEnabled, required this.nearestRelayFallbackEnabled,
required this.clearPathOnMaxRetry, required this.clearPathOnMaxRetry,
}); });
@@ -968,7 +775,7 @@ class _AutomationRoutingInfo extends StatelessWidget {
if (isExpanded) ...[ if (isExpanded) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Room/contact sends keep one selected path for the whole send chain, retry up to 5 total attempts with 1s, 2s, 4s, and 8s backoff, then try one final nearest repeater if everything else fails.', 'Room/contact sends use the current direct path when one is known, switch to flood on the last normal retry, then try one final nearest repeater if everything else fails.',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -981,12 +788,6 @@ class _AutomationRoutingInfo extends StatelessWidget {
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: [ children: [
_InfoChip(
label: autoRouteRotationEnabled
? 'Auto route rotation on'
: 'Auto route rotation off',
icon: Icons.swap_horiz,
),
_InfoChip( _InfoChip(
label: nearestRelayFallbackEnabled label: nearestRelayFallbackEnabled
? 'Nearest repeater fallback on' ? 'Nearest repeater fallback on'
@@ -1004,7 +805,7 @@ class _AutomationRoutingInfo extends StatelessWidget {
] else ...[ ] else ...[
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
'Shows retry, rotation, and final repeater fallback behavior.', 'Shows retry and final repeater fallback behavior.',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
], ],

View File

@@ -850,12 +850,6 @@ class ContactTile extends StatelessWidget {
signedEncodedPathLen: parsedRoute.signedEncodedPathLen, signedEncodedPathLen: parsedRoute.signedEncodedPathLen,
paddedPathBytes: parsedRoute.paddedPathBytes, paddedPathBytes: parsedRoute.paddedPathBytes,
); );
await pathHistoryService.clearHistoryForContact(
contact.copyWith(
outPathLen: parsedRoute.signedEncodedPathLen,
outPath: Uint8List.fromList(parsedRoute.paddedPathBytes),
),
);
await pathHistoryService.setManualRouteForContact(contact, parsedRoute); await pathHistoryService.setManualRouteForContact(contact, parsedRoute);
if (context.mounted) { if (context.mounted) {
final routeLabel = parsedRoute.hopCount == 0 final routeLabel = parsedRoute.hopCount == 0

View File

@@ -10,13 +10,16 @@ import 'package:meshcore_sar_app/providers/helpers/message_retry_manager.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart'; import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
Contact _buildContact() { Contact _buildContact({
int outPathLen = 1,
List<int> outPath = const [1, 2, 3, 4],
}) {
return Contact( return Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)), publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)),
type: ContactType.chat, type: ContactType.chat,
flags: 0, flags: 0,
outPathLen: 1, outPathLen: outPathLen,
outPath: Uint8List.fromList([1, 2, 3, 4]), outPath: Uint8List.fromList(outPath),
advName: 'Teammate', advName: 'Teammate',
lastAdvert: 1700000000, lastAdvert: 1700000000,
advLat: 0, advLat: 0,
@@ -162,6 +165,42 @@ void main() {
expect(provider.messages.single.roundTripTimeMs, 190); expect(provider.messages.single.roundTripTimeMs, 190);
}); });
test('delivered flood message upgrades to learned direct route from ACK path', () {
final provider = MessagesProvider();
final contactWithoutRoute = _buildContact(outPathLen: -1, outPath: []);
provider.addSentMessage(
_buildDirectMessage('m1d'),
contact: contactWithoutRoute,
);
provider.updateMessageRouteSelection(
'm1d',
PathSelection.flood(),
routerFallbackAttempted: false,
);
provider.markMessageSent('m1d', 80, 250);
provider.markMessageDelivered(80, 200);
provider.queueDeliveredMessageRouteRefresh('m1d', contactWithoutRoute);
final applied = provider.applyDeliveredMessageRouteFromContact(
_buildContact(outPathLen: 2, outPath: const [0xAA, 0xBB]),
);
expect(applied, isTrue);
expect(provider.messages.single.deliveryStatus, MessageDeliveryStatus.delivered);
expect(provider.messages.single.usedFloodFallback, isFalse);
expect(provider.messages.single.pathLen, 2);
expect(
provider.getMessageRouteMetadata('m1d')?.mode,
PathSelectionMode.directCurrent,
);
expect(
provider.getMessageRouteMetadata('m1d')?.canonicalPath,
'AA,BB',
);
expect(provider.getMessageRouteMetadata('m1d')?.hopCount, 2);
});
test('channel messages are marked sent immediately', () { test('channel messages are marked sent immediately', () {
final provider = MessagesProvider(); final provider = MessagesProvider();
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)'; provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';

View File

@@ -10,11 +10,7 @@ void main() {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
}); });
test('route preference defaults are disabled', () async { test('route preference defaults are clear-path disabled and fallback enabled', () async {
expect(
await MessagingRoutePreferences.getAutoRouteRotationEnabled(),
isFalse,
);
expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isFalse); expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isFalse);
expect( expect(
await MessagingRoutePreferences.getNearestRelayFallbackEnabled(), await MessagingRoutePreferences.getNearestRelayFallbackEnabled(),
@@ -23,18 +19,29 @@ void main() {
}); });
test('route preferences persist changes', () async { test('route preferences persist changes', () async {
await MessagingRoutePreferences.setAutoRouteRotationEnabled(true);
await MessagingRoutePreferences.setClearPathOnMaxRetry(true); await MessagingRoutePreferences.setClearPathOnMaxRetry(true);
await MessagingRoutePreferences.setNearestRelayFallbackEnabled(false); await MessagingRoutePreferences.setNearestRelayFallbackEnabled(false);
expect(
await MessagingRoutePreferences.getAutoRouteRotationEnabled(),
isTrue,
);
expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isTrue); expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isTrue);
expect( expect(
await MessagingRoutePreferences.getNearestRelayFallbackEnabled(), await MessagingRoutePreferences.getNearestRelayFallbackEnabled(),
isFalse, isFalse,
); );
}); });
test('legacy auto route rotation preference is removed during cleanup', () async {
SharedPreferences.setMockInitialValues({
'messaging_auto_route_rotation_enabled': true,
});
final prefs = await SharedPreferences.getInstance();
expect(prefs.getBool('messaging_auto_route_rotation_enabled'), isTrue);
await MessagingRoutePreferences.cleanupLegacySettings();
expect(
prefs.containsKey('messaging_auto_route_rotation_enabled'),
isFalse,
);
});
} }

View File

@@ -1,44 +1,31 @@
import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:meshcore_sar_app/models/contact.dart'; import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/path_history.dart';
import 'package:meshcore_sar_app/models/path_selection.dart'; import 'package:meshcore_sar_app/models/path_selection.dart';
import 'package:meshcore_sar_app/services/path_history_service.dart'; import 'package:meshcore_sar_app/services/path_history_service.dart';
Contact _buildContact({ Contact _buildContact({
required int seed, required int seed,
required List<int> pathBytes, List<int> pathBytes = const [],
required int hopCount, int hopCount = 0,
required int hashSize, int hashSize = 1,
}) { }) {
final encoded = ((hashSize - 1) << 6) | (hopCount & 0x3F); final encoded = pathBytes.isEmpty ? -1 : ((hashSize - 1) << 6) | (hopCount & 0x3F);
final outPath = Uint8List(ContactRouteCodec.maxPathBytes) final outPath = Uint8List(ContactRouteCodec.maxPathBytes);
..setRange(0, pathBytes.length, pathBytes); if (pathBytes.isNotEmpty) {
outPath.setRange(0, pathBytes.length, pathBytes);
}
return Contact( return Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i + seed)), publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i + seed)),
type: ContactType.chat, type: ContactType.chat,
flags: 0, flags: 0,
outPathLen: ContactRouteCodec.toSignedDescriptor(encoded), outPathLen: encoded == -1 ? -1 : ContactRouteCodec.toSignedDescriptor(encoded),
outPath: outPath, outPath: encoded == -1 ? Uint8List(0) : outPath,
advName: 'Contact $seed',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}
Contact _buildContactWithoutRoute({required int seed}) {
return Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i + seed)),
type: ContactType.chat,
flags: 0,
outPathLen: -1,
outPath: Uint8List(0),
advName: 'Contact $seed', advName: 'Contact $seed',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0, advLat: 0,
@@ -54,117 +41,10 @@ void main() {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
}); });
test('auto rotation ranks best paths before flood', () async { test('manual route override persists across reloads', () async {
final contact = _buildContact(seed: 1);
final service = PathHistoryService(); final service = PathHistoryService();
final contact = _buildContactWithoutRoute(seed: 0);
final best = PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList([0xAA, 0xBB]),
hopCount: 2,
hashSize: 1,
);
final second = PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList([0xCC, 0xDD]),
hopCount: 2,
hashSize: 1,
);
await service.initialize(); await service.initialize();
await service.recordPathResult(
contact.publicKeyHex,
best,
success: true,
roundTripTimeMs: 120,
);
await service.recordPathResult(
contact.publicKeyHex,
best,
success: true,
roundTripTimeMs: 110,
);
await service.recordPathResult(
contact.publicKeyHex,
second,
success: true,
roundTripTimeMs: 200,
);
await service.recordPathResult(
contact.publicKeyHex,
second,
success: false,
);
final first = await service.getSelectionForContact(
contact,
autoRouteRotationEnabled: true,
);
final third = await service.getSelectionForContact(
contact,
autoRouteRotationEnabled: true,
);
final secondPick = await service.getSelectionForContact(
contact,
autoRouteRotationEnabled: true,
);
expect(first.mode, PathSelectionMode.directHistorical);
expect(first.canonicalPath, 'AA,BB');
expect(third.mode, PathSelectionMode.directHistorical);
expect(third.canonicalPath, 'CC,DD');
expect(secondPick.mode, PathSelectionMode.flood);
});
test(
'contact route alone does not override history selection',
() async {
final service = PathHistoryService();
final contact = _buildContact(
seed: 9,
pathBytes: [0xAA, 0xBB, 0xCC],
hopCount: 1,
hashSize: 3,
);
await service.initialize();
await service.recordPathResult(
contact.publicKeyHex,
PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList([0x11, 0x22, 0x33]),
hopCount: 1,
hashSize: 3,
),
success: true,
roundTripTimeMs: 90,
);
final selection = await service.getSelectionForContact(
contact,
autoRouteRotationEnabled: true,
);
expect(selection.mode, PathSelectionMode.directHistorical);
expect(selection.canonicalPath, '112233');
},
);
test('manual route overrides history selection until cleared', () async {
final service = PathHistoryService();
final contact = _buildContactWithoutRoute(seed: 10);
await service.initialize();
await service.recordPathResult(
contact.publicKeyHex,
PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList([0x11, 0x22]),
hopCount: 2,
hashSize: 1,
),
success: true,
roundTripTimeMs: 100,
);
await service.setManualSelectionFor( await service.setManualSelectionFor(
contact.publicKeyHex, contact.publicKeyHex,
PathSelection( PathSelection(
@@ -175,237 +55,129 @@ void main() {
), ),
); );
final selection = await service.getSelectionForContact( final reloaded = PathHistoryService();
contact, final selection = await reloaded.getManualSelectionForContact(contact);
autoRouteRotationEnabled: true,
);
expect(selection.mode, PathSelectionMode.directCurrent); expect(selection, isNotNull);
expect(selection!.mode, PathSelectionMode.directCurrent);
expect(selection.canonicalPath, 'AA,BB'); expect(selection.canonicalPath, 'AA,BB');
}); });
test('no history falls back to flood', () async { test('selection uses stored manual route before contact route', () async {
final service = PathHistoryService(); final contact = _buildContact(
final contact = _buildContactWithoutRoute(seed: 0); seed: 2,
pathBytes: const [0x11, 0x22],
final selection = await service.getSelectionForContact( hopCount: 2,
contact, hashSize: 1,
autoRouteRotationEnabled: true,
); );
expect(selection.mode, PathSelectionMode.flood);
});
test(
'received public byte path is reversed before adding to history',
() async {
final service = PathHistoryService();
await service.initialize();
await service.recordReceivedBytePath('abc123', [
0x01,
0x02,
0x03,
0x04,
], 2);
final history = service.historyFor('abc123');
expect(history.directPaths, hasLength(1));
expect(history.directPaths.single.pathBytes, [0x03, 0x04, 0x01, 0x02]);
expect(history.directPaths.single.hashSize, 2);
expect(history.directPaths.single.hopCount, 2);
expect(history.directPaths.single.source, PathRecordSource.observed);
},
);
test(
'observed paths stay marked as observed until delivery succeeds',
() async {
final service = PathHistoryService();
final contact = _buildContact(
seed: 3,
pathBytes: [0xAA, 0xBB],
hopCount: 2,
hashSize: 1,
);
await service.initialize();
await service.recordReceivedBytePath(contact.publicKeyHex, [
0xBB,
0xAA,
], 1);
final history = service.historyFor(contact.publicKeyHex);
expect(history.directPaths, hasLength(1));
expect(history.directPaths.single.source, PathRecordSource.observed);
},
);
test(
'confirmed direct delivery promotes an observed path to learned',
() async {
final service = PathHistoryService();
final contact = _buildContact(
seed: 4,
pathBytes: [0xAA, 0xBB],
hopCount: 2,
hashSize: 1,
);
await service.initialize();
await service.recordReceivedBytePath(contact.publicKeyHex, [
0xBB,
0xAA,
], 1);
await service.recordPathResult(
contact.publicKeyHex,
PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList([0xAA, 0xBB]),
hopCount: 2,
hashSize: 1,
),
success: true,
roundTripTimeMs: 150,
);
final history = service.historyFor(contact.publicKeyHex);
expect(history.directPaths, hasLength(1));
expect(history.directPaths.single.source, PathRecordSource.learned);
expect(history.directPaths.single.successCount, 1);
expect(history.directPaths.single.lastRoundTripTimeMs, 150);
},
);
test('clear history removes stored direct paths for one contact', () async {
final service = PathHistoryService(); final service = PathHistoryService();
await service.initialize();
await service.recordReceivedBytePath('abc123', [0x01, 0x02], 1);
await service.recordReceivedBytePath('def456', [0x03, 0x04], 1);
expect(service.historyFor('abc123').directPaths, hasLength(1));
expect(service.historyFor('def456').directPaths, hasLength(1));
await service.clearHistoryFor('abc123');
expect(service.historyFor('abc123').directPaths, isEmpty);
expect(service.historyFor('def456').directPaths, hasLength(1));
});
test('clearing manual route falls back to flood without history', () async {
final service = PathHistoryService();
final contact = _buildContactWithoutRoute(seed: 11);
await service.initialize(); await service.initialize();
await service.setManualSelectionFor( await service.setManualSelectionFor(
contact.publicKeyHex, contact.publicKeyHex,
PathSelection( PathSelection(
mode: PathSelectionMode.directCurrent, mode: PathSelectionMode.directCurrent,
pathBytes: Uint8List.fromList([0xAA]), pathBytes: Uint8List.fromList([0xAA, 0xBB]),
hopCount: 1, hopCount: 2,
hashSize: 1,
),
);
final selection = await service.getSelectionForContact(contact);
expect(selection.mode, PathSelectionMode.directCurrent);
expect(selection.canonicalPath, 'AA,BB');
});
test('selection falls back to the current contact route', () async {
final contact = _buildContact(
seed: 3,
pathBytes: const [0x10, 0x20, 0x30],
hopCount: 1,
hashSize: 3,
);
final service = PathHistoryService();
final selection = await service.getSelectionForContact(contact);
expect(selection.mode, PathSelectionMode.directCurrent);
expect(selection.canonicalPath, '102030');
expect(selection.hashSize, 3);
expect(selection.hopCount, 1);
});
test('selection falls back to flood when no route exists', () async {
final contact = _buildContact(seed: 4);
final service = PathHistoryService();
final selection = await service.getSelectionForContact(contact);
expect(selection.mode, PathSelectionMode.flood);
expect(selection.pathBytes, isEmpty);
});
test('clearing manual route falls back to the contact route', () async {
final contact = _buildContact(
seed: 5,
pathBytes: const [0x01, 0x02],
hopCount: 2,
hashSize: 1,
);
final service = PathHistoryService();
await service.initialize();
await service.setManualSelectionFor(
contact.publicKeyHex,
PathSelection(
mode: PathSelectionMode.directCurrent,
pathBytes: Uint8List.fromList([0xAA, 0xBB]),
hopCount: 2,
hashSize: 1, hashSize: 1,
), ),
); );
await service.clearManualRouteFor(contact.publicKeyHex); await service.clearManualRouteFor(contact.publicKeyHex);
final selection = await service.getSelectionForContact(contact);
final selection = await service.getSelectionForContact( expect(selection.mode, PathSelectionMode.directCurrent);
contact, expect(selection.canonicalPath, '01,02');
autoRouteRotationEnabled: true,
);
expect(selection.mode, PathSelectionMode.flood);
}); });
test( test('initialize removes legacy path history storage', () async {
'clear history for contact leaves the contact route ignored', final contact = Contact(
() async { publicKey: Uint8List.fromList([
final service = PathHistoryService(); 0xAB,
final contact = _buildContact( 0xC1,
seed: 5, 0x23,
pathBytes: [0xAA, 0xBB], ...List<int>.filled(29, 0),
hopCount: 2, ]),
hashSize: 1, type: ContactType.chat,
); flags: 0,
outPathLen: -1,
await service.initialize(); outPath: Uint8List(0),
await service.recordPathResult( advName: 'Legacy Contact',
contact.publicKeyHex, lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
PathSelection( advLat: 0,
mode: PathSelectionMode.directHistorical, advLon: 0,
pathBytes: Uint8List.fromList([0xAA, 0xBB]), lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
hopCount: 2,
hashSize: 1,
),
success: true,
roundTripTimeMs: 120,
);
expect(service.historyFor(contact.publicKeyHex).directPaths, hasLength(1));
await service.clearHistoryForContact(contact);
expect(service.historyFor(contact.publicKeyHex).directPaths, isEmpty);
final selection = await service.getSelectionForContact(
contact,
autoRouteRotationEnabled: true,
);
expect(selection.mode, PathSelectionMode.flood);
},
);
test('last successful direct path is chosen by location fit', () async {
final service = PathHistoryService();
final contact = _buildContact(
seed: 7,
pathBytes: [0xAA],
hopCount: 1,
hashSize: 1,
); );
SharedPreferences.setMockInitialValues({
'contact_path_history_v2': '{"abc123":{"direct_paths":[]}}',
'contact_manual_path_overrides_v1': jsonEncode({
contact.publicKeyHex: {
'pathBytes': [0xAA, 0xBB],
'hopCount': 2,
'hashSize': 1,
},
}),
});
final service = PathHistoryService();
await service.initialize(); await service.initialize();
await service.recordPathResult(
contact.publicKeyHex,
PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList([0x11]),
hopCount: 1,
hashSize: 1,
),
success: true,
roundTripTimeMs: 120,
senderLatitude: 46.0,
senderLongitude: 14.0,
recipientLatitude: 46.1,
recipientLongitude: 14.1,
);
await service.recordPathResult(
contact.publicKeyHex,
PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList([0x22]),
hopCount: 1,
hashSize: 1,
),
success: true,
roundTripTimeMs: 90,
senderLatitude: 46.0001,
senderLongitude: 14.0001,
recipientLatitude: 46.1001,
recipientLongitude: 14.1001,
);
final selection = await service.getLastSuccessfulDirectSelection(
contact,
excludeSignature: 'aa',
senderLatitude: 46.0002,
senderLongitude: 14.0002,
recipientLatitude: 46.1002,
recipientLongitude: 14.1002,
);
final prefs = await SharedPreferences.getInstance();
expect(prefs.containsKey('contact_path_history_v2'), isFalse);
final selection = await service.getManualSelectionForContact(contact);
expect(selection, isNotNull); expect(selection, isNotNull);
expect(selection!.mode, PathSelectionMode.directHistorical); expect(selection!.canonicalPath, 'AA,BB');
expect(selection.canonicalPath, '22');
}); });
} }