mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
fix: Widen worker stats graph
This commit is contained in:
@@ -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?,
|
||||||
|
|||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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!);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, isNotNull);
|
||||||
|
expect(selection!.mode, PathSelectionMode.directCurrent);
|
||||||
|
expect(selection.canonicalPath, 'AA,BB');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selection uses stored manual route before contact route', () async {
|
||||||
|
final contact = _buildContact(
|
||||||
|
seed: 2,
|
||||||
|
pathBytes: const [0x11, 0x22],
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final selection = await service.getSelectionForContact(contact);
|
||||||
|
|
||||||
expect(selection.mode, PathSelectionMode.directCurrent);
|
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 falls back to the current contact route', () async {
|
||||||
final service = PathHistoryService();
|
|
||||||
final contact = _buildContactWithoutRoute(seed: 0);
|
|
||||||
|
|
||||||
final selection = await service.getSelectionForContact(
|
|
||||||
contact,
|
|
||||||
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(
|
final contact = _buildContact(
|
||||||
seed: 3,
|
seed: 3,
|
||||||
pathBytes: [0xAA, 0xBB],
|
pathBytes: const [0x10, 0x20, 0x30],
|
||||||
hopCount: 2,
|
hopCount: 1,
|
||||||
hashSize: 1,
|
hashSize: 3,
|
||||||
);
|
);
|
||||||
|
|
||||||
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();
|
final selection = await service.getSelectionForContact(contact);
|
||||||
await service.recordReceivedBytePath('abc123', [0x01, 0x02], 1);
|
|
||||||
await service.recordReceivedBytePath('def456', [0x03, 0x04], 1);
|
|
||||||
|
|
||||||
expect(service.historyFor('abc123').directPaths, hasLength(1));
|
expect(selection.mode, PathSelectionMode.directCurrent);
|
||||||
expect(service.historyFor('def456').directPaths, hasLength(1));
|
expect(selection.canonicalPath, '102030');
|
||||||
|
expect(selection.hashSize, 3);
|
||||||
await service.clearHistoryFor('abc123');
|
expect(selection.hopCount, 1);
|
||||||
|
|
||||||
expect(service.historyFor('abc123').directPaths, isEmpty);
|
|
||||||
expect(service.historyFor('def456').directPaths, hasLength(1));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clearing manual route falls back to flood without history', () async {
|
test('selection falls back to flood when no route exists', () async {
|
||||||
|
final contact = _buildContact(seed: 4);
|
||||||
final service = PathHistoryService();
|
final service = PathHistoryService();
|
||||||
final contact = _buildContactWithoutRoute(seed: 11);
|
|
||||||
|
|
||||||
|
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.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,
|
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,
|
||||||
|
outPath: Uint8List(0),
|
||||||
|
advName: 'Legacy Contact',
|
||||||
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
advLat: 0,
|
||||||
|
advLon: 0,
|
||||||
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.initialize();
|
SharedPreferences.setMockInitialValues({
|
||||||
await service.recordPathResult(
|
'contact_path_history_v2': '{"abc123":{"direct_paths":[]}}',
|
||||||
contact.publicKeyHex,
|
'contact_manual_path_overrides_v1': jsonEncode({
|
||||||
PathSelection(
|
contact.publicKeyHex: {
|
||||||
mode: PathSelectionMode.directHistorical,
|
'pathBytes': [0xAA, 0xBB],
|
||||||
pathBytes: Uint8List.fromList([0xAA, 0xBB]),
|
'hopCount': 2,
|
||||||
hopCount: 2,
|
'hashSize': 1,
|
||||||
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 service = PathHistoryService();
|
||||||
final contact = _buildContact(
|
|
||||||
seed: 7,
|
|
||||||
pathBytes: [0xAA],
|
|
||||||
hopCount: 1,
|
|
||||||
hashSize: 1,
|
|
||||||
);
|
|
||||||
|
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user