mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Fix retry flow and contact filters
This commit is contained in:
@@ -1293,6 +1293,9 @@ class AppProvider with ChangeNotifier {
|
|||||||
retryAttempt: retryAttempt,
|
retryAttempt: retryAttempt,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
connectionProvider.resolveContactForDmCallback = (contactPublicKey) {
|
||||||
|
return contactsProvider.findContactByKey(contactPublicKey);
|
||||||
|
};
|
||||||
messagesProvider.onFinalRouterFallbackCallback =
|
messagesProvider.onFinalRouterFallbackCallback =
|
||||||
({required messageId, required contact, required message}) async {
|
({required messageId, required contact, required message}) async {
|
||||||
return _sendWithFinalNearestRouterFallback(
|
return _sendWithFinalNearestRouterFallback(
|
||||||
@@ -1321,6 +1324,9 @@ class AppProvider with ChangeNotifier {
|
|||||||
roundTripTimeMs: roundTripTimeMs,
|
roundTripTimeMs: roundTripTimeMs,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
messagesProvider.onManualRetryPreparedCallback = (messageId) {
|
||||||
|
_directMessageRouteSessions.remove(messageId);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Contact> _prepareDirectMessageSend({
|
Future<Contact> _prepareDirectMessageSend({
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
onMessageEchoDetected;
|
onMessageEchoDetected;
|
||||||
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
|
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
|
||||||
Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived;
|
Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived;
|
||||||
|
Contact? Function(Uint8List contactPublicKey)? resolveContactForDmCallback;
|
||||||
|
|
||||||
// Track pending send operations for auto-recovery
|
// Track pending send operations for auto-recovery
|
||||||
final Map<String, _PendingSendOperation> _pendingSendOperations = {};
|
final Map<String, _PendingSendOperation> _pendingSendOperations = {};
|
||||||
@@ -1164,6 +1165,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var effectiveContact = contact;
|
var effectiveContact = contact;
|
||||||
|
effectiveContact ??= resolveContactForDmCallback?.call(contactPublicKey);
|
||||||
if (messageId != null &&
|
if (messageId != null &&
|
||||||
effectiveContact != null &&
|
effectiveContact != null &&
|
||||||
prepareDirectMessageSendCallback != null) {
|
prepareDirectMessageSendCallback != null) {
|
||||||
|
|||||||
@@ -844,16 +844,30 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
Uint8List publicKey, {
|
Uint8List publicKey, {
|
||||||
required int signedEncodedPathLen,
|
required int signedEncodedPathLen,
|
||||||
required Uint8List paddedPathBytes,
|
required Uint8List paddedPathBytes,
|
||||||
|
LatLng? inferredFallbackLocation,
|
||||||
}) {
|
}) {
|
||||||
final contact = findContactByKey(publicKey);
|
final contact = findContactByKey(publicKey);
|
||||||
if (contact == null) {
|
if (contact == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_contacts[contact.publicKeyHex] = contact.copyWith(
|
var updatedContact = contact.copyWith(
|
||||||
outPathLen: signedEncodedPathLen,
|
outPathLen: signedEncodedPathLen,
|
||||||
outPath: Uint8List.fromList(paddedPathBytes),
|
outPath: Uint8List.fromList(paddedPathBytes),
|
||||||
);
|
);
|
||||||
|
if (inferredFallbackLocation != null) {
|
||||||
|
updatedContact = updatedContact
|
||||||
|
.copyWith(
|
||||||
|
advLat: _coordinateToAdvertMicrodegrees(
|
||||||
|
inferredFallbackLocation.latitude,
|
||||||
|
),
|
||||||
|
advLon: _coordinateToAdvertMicrodegrees(
|
||||||
|
inferredFallbackLocation.longitude,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addAdvertLocation(inferredFallbackLocation, DateTime.now());
|
||||||
|
}
|
||||||
|
_contacts[contact.publicKeyHex] = updatedContact;
|
||||||
_persistContacts();
|
_persistContacts();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> Function({required Contact contact, required int failureStreak})?
|
Future<void> Function({required Contact contact, required int failureStreak})?
|
||||||
onDirectPathFailedCallback;
|
onDirectPathFailedCallback;
|
||||||
|
void Function(String messageId)? onManualRetryPreparedCallback;
|
||||||
Future<bool> Function({
|
Future<bool> Function({
|
||||||
required String messageId,
|
required String messageId,
|
||||||
required Contact contact,
|
required Contact contact,
|
||||||
@@ -165,8 +166,12 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
final index = _messages.indexWhere((message) => message.id == messageId);
|
final index = _messages.indexWhere((message) => message.id == messageId);
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
|
final nextPathLen = selection.hopCount > 0
|
||||||
|
? selection.hopCount
|
||||||
|
: _messages[index].pathLen;
|
||||||
_messages[index] = _messages[index].copyWith(
|
_messages[index] = _messages[index].copyWith(
|
||||||
usedFloodFallback: selection.usesFlood,
|
usedFloodFallback: selection.usesFlood,
|
||||||
|
pathLen: nextPathLen,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2069,6 +2074,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
_clearAckHistoryForMessage(messageId);
|
_clearAckHistoryForMessage(messageId);
|
||||||
_retryManager.clearRetry(messageId);
|
_retryManager.clearRetry(messageId);
|
||||||
_messageRouteMetadata.remove(messageId);
|
_messageRouteMetadata.remove(messageId);
|
||||||
|
onManualRetryPreparedCallback?.call(messageId);
|
||||||
|
|
||||||
_messages[index] = Message(
|
_messages[index] = Message(
|
||||||
id: message.id,
|
id: message.id,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class ContactsTab extends StatefulWidget {
|
|||||||
class _ContactsTabState extends State<ContactsTab> {
|
class _ContactsTabState extends State<ContactsTab> {
|
||||||
Position? _currentPosition;
|
Position? _currentPosition;
|
||||||
final Set<String> _resolvingAdvertKeys = <String>{};
|
final Set<String> _resolvingAdvertKeys = <String>{};
|
||||||
|
bool _isResolvingPendingBatch = false;
|
||||||
final Map<ContactSection, ContactSortMode> _sortModes = {
|
final Map<ContactSection, ContactSortMode> _sortModes = {
|
||||||
ContactSection.teamMembers: ContactSortMode.lastSeen,
|
ContactSection.teamMembers: ContactSortMode.lastSeen,
|
||||||
ContactSection.repeaters: ContactSortMode.lastSeen,
|
ContactSection.repeaters: ContactSortMode.lastSeen,
|
||||||
@@ -95,6 +96,38 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _schedulePendingAdvertResolution(
|
||||||
|
List<PendingAdvert> pendingAdverts,
|
||||||
|
ConnectionProvider connectionProvider,
|
||||||
|
) {
|
||||||
|
if (_isResolvingPendingBatch ||
|
||||||
|
!connectionProvider.deviceInfo.isConnected ||
|
||||||
|
pendingAdverts.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final advertsToResolve = pendingAdverts
|
||||||
|
.where((advert) => !_resolvingAdvertKeys.contains(advert.publicKeyHex))
|
||||||
|
.toList();
|
||||||
|
if (advertsToResolve.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
if (!mounted || _isResolvingPendingBatch) return;
|
||||||
|
|
||||||
|
_isResolvingPendingBatch = true;
|
||||||
|
try {
|
||||||
|
for (final advert in advertsToResolve) {
|
||||||
|
if (!mounted) break;
|
||||||
|
await _handleResolveAdvert(advert);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_isResolvingPendingBatch = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Calculate distance between two points in meters
|
/// Calculate distance between two points in meters
|
||||||
double _calculateDistanceInMeters(
|
double _calculateDistanceInMeters(
|
||||||
double lat1,
|
double lat1,
|
||||||
@@ -230,6 +263,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
body: Consumer<ContactsProvider>(
|
body: Consumer<ContactsProvider>(
|
||||||
builder: (context, contactsProvider, child) {
|
builder: (context, contactsProvider, child) {
|
||||||
final messagesProvider = context.watch<MessagesProvider>();
|
final messagesProvider = context.watch<MessagesProvider>();
|
||||||
|
final connectionProvider = context.watch<ConnectionProvider>();
|
||||||
final chatContacts = _sortContacts(
|
final chatContacts = _sortContacts(
|
||||||
contactsProvider.chatContacts,
|
contactsProvider.chatContacts,
|
||||||
ContactSection.teamMembers,
|
ContactSection.teamMembers,
|
||||||
@@ -248,6 +282,8 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
);
|
);
|
||||||
final pendingAdverts = contactsProvider.pendingAdverts;
|
final pendingAdverts = contactsProvider.pendingAdverts;
|
||||||
|
|
||||||
|
_schedulePendingAdvertResolution(pendingAdverts, connectionProvider);
|
||||||
|
|
||||||
// Check if there are any displayable contacts
|
// Check if there are any displayable contacts
|
||||||
final hasDisplayableContacts =
|
final hasDisplayableContacts =
|
||||||
chatContacts.isNotEmpty ||
|
chatContacts.isNotEmpty ||
|
||||||
@@ -287,27 +323,6 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
children: [
|
children: [
|
||||||
// Pending adverts (public key only; quick resolve)
|
|
||||||
if (pendingAdverts.isNotEmpty) ...[
|
|
||||||
_SectionHeader(
|
|
||||||
title: l10n.pending,
|
|
||||||
count: pendingAdverts.length,
|
|
||||||
icon: Icons.person_add_alt_1,
|
|
||||||
),
|
|
||||||
...pendingAdverts.map(
|
|
||||||
(advert) => _PendingAdvertTile(
|
|
||||||
advert: advert,
|
|
||||||
subtitle:
|
|
||||||
'${l10n.publicKey}: ${advert.publicKeyHex}\n${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}',
|
|
||||||
isResolving: _resolvingAdvertKeys.contains(
|
|
||||||
advert.publicKeyHex,
|
|
||||||
),
|
|
||||||
onResolve: () => _handleResolveAdvert(advert),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(height: 32),
|
|
||||||
],
|
|
||||||
|
|
||||||
// Team Members (Chat contacts)
|
// Team Members (Chat contacts)
|
||||||
if (chatContacts.isNotEmpty) ...[
|
if (chatContacts.isNotEmpty) ...[
|
||||||
_SectionHeader(
|
_SectionHeader(
|
||||||
@@ -347,6 +362,27 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Pending adverts are kept below resolved sections while we load details.
|
||||||
|
if (pendingAdverts.isNotEmpty) ...[
|
||||||
|
_SectionHeader(
|
||||||
|
title: l10n.pending,
|
||||||
|
count: pendingAdverts.length,
|
||||||
|
icon: Icons.person_search,
|
||||||
|
),
|
||||||
|
...pendingAdverts.map(
|
||||||
|
(advert) => _PendingAdvertTile(
|
||||||
|
advert: advert,
|
||||||
|
subtitle:
|
||||||
|
'${l10n.publicKey}: ${advert.shortDisplayKey}\n${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}',
|
||||||
|
isResolving: _resolvingAdvertKeys.contains(
|
||||||
|
advert.publicKeyHex,
|
||||||
|
),
|
||||||
|
onResolve: () => _handleResolveAdvert(advert),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 32),
|
||||||
|
],
|
||||||
|
|
||||||
// Channels (visible in both simple and advanced mode)
|
// Channels (visible in both simple and advanced mode)
|
||||||
_SectionHeader(
|
_SectionHeader(
|
||||||
title: l10n.channels,
|
title: l10n.channels,
|
||||||
@@ -513,7 +549,7 @@ class _PendingAdvertTile extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
: IconButton(
|
: IconButton(
|
||||||
icon: const Icon(Icons.person_add_alt_1),
|
icon: const Icon(Icons.person_add_alt_1),
|
||||||
tooltip: 'Quick add',
|
tooltip: 'Resolve contact',
|
||||||
onPressed: onResolve,
|
onPressed: onResolve,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
336
lib/services/contact_route_resolver.dart
Normal file
336
lib/services/contact_route_resolver.dart
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
import '../models/contact.dart';
|
||||||
|
|
||||||
|
class ResolvedContactRoutePlan {
|
||||||
|
final List<String> tokens;
|
||||||
|
final List<Contact> selectedContacts;
|
||||||
|
final String summary;
|
||||||
|
|
||||||
|
const ResolvedContactRoutePlan({
|
||||||
|
required this.tokens,
|
||||||
|
required this.selectedContacts,
|
||||||
|
required this.summary,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get canonicalText => tokens.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContactRouteResolver {
|
||||||
|
static const Distance _distance = Distance();
|
||||||
|
|
||||||
|
const ContactRouteResolver._();
|
||||||
|
|
||||||
|
static ResolvedContactRoutePlan? resolveAutomaticRoute({
|
||||||
|
required LatLng senderLocation,
|
||||||
|
required Contact recipient,
|
||||||
|
required List<Contact> availableContacts,
|
||||||
|
required int hashSize,
|
||||||
|
}) {
|
||||||
|
final recipientLocation = recipient.displayLocation;
|
||||||
|
if (recipientLocation == null) return null;
|
||||||
|
|
||||||
|
final repeaters = availableContacts
|
||||||
|
.where(
|
||||||
|
(contact) =>
|
||||||
|
contact.isRepeater &&
|
||||||
|
contact.displayLocation != null &&
|
||||||
|
contact.publicKeyHex != recipient.publicKeyHex,
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
if (repeaters.isEmpty) return null;
|
||||||
|
|
||||||
|
final recipientLatLng = LatLng(
|
||||||
|
recipientLocation.latitude,
|
||||||
|
recipientLocation.longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
final routedCandidates =
|
||||||
|
repeaters
|
||||||
|
.where(
|
||||||
|
(contact) =>
|
||||||
|
contact.routeHasPath && contact.routeHashSize == hashSize,
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
|
..sort(
|
||||||
|
(a, b) =>
|
||||||
|
_scoreKnownRouteRepeater(
|
||||||
|
senderLocation: senderLocation,
|
||||||
|
recipientLocation: recipientLatLng,
|
||||||
|
repeater: a,
|
||||||
|
availableContacts: repeaters,
|
||||||
|
hashSize: hashSize,
|
||||||
|
).compareTo(
|
||||||
|
_scoreKnownRouteRepeater(
|
||||||
|
senderLocation: senderLocation,
|
||||||
|
recipientLocation: recipientLatLng,
|
||||||
|
repeater: b,
|
||||||
|
availableContacts: repeaters,
|
||||||
|
hashSize: hashSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (routedCandidates.isNotEmpty) {
|
||||||
|
final anchor = routedCandidates.first;
|
||||||
|
final tokens = <String>[
|
||||||
|
...anchor.routeCanonicalText
|
||||||
|
.split(',')
|
||||||
|
.where((token) => token.isNotEmpty)
|
||||||
|
.map((token) => token.toUpperCase()),
|
||||||
|
_tokenFor(anchor, hashSize),
|
||||||
|
];
|
||||||
|
final selectedContacts = _matchContactsForTokens(
|
||||||
|
tokens,
|
||||||
|
availableContacts: repeaters,
|
||||||
|
);
|
||||||
|
return ResolvedContactRoutePlan(
|
||||||
|
tokens: _dedupeTokens(tokens),
|
||||||
|
selectedContacts: selectedContacts,
|
||||||
|
summary: 'Resolved via known repeater route',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final corridorRepeaters = List<Contact>.from(repeaters)
|
||||||
|
..sort((a, b) {
|
||||||
|
final progressA = _progressAlongSegment(
|
||||||
|
point: LatLng(
|
||||||
|
a.displayLocation!.latitude,
|
||||||
|
a.displayLocation!.longitude,
|
||||||
|
),
|
||||||
|
start: senderLocation,
|
||||||
|
end: recipientLatLng,
|
||||||
|
);
|
||||||
|
final progressB = _progressAlongSegment(
|
||||||
|
point: LatLng(
|
||||||
|
b.displayLocation!.latitude,
|
||||||
|
b.displayLocation!.longitude,
|
||||||
|
),
|
||||||
|
start: senderLocation,
|
||||||
|
end: recipientLatLng,
|
||||||
|
);
|
||||||
|
final progressCompare = progressA.compareTo(progressB);
|
||||||
|
if (progressCompare != 0) return progressCompare;
|
||||||
|
return _scoreRepeater(
|
||||||
|
senderLocation: senderLocation,
|
||||||
|
recipientLocation: recipientLatLng,
|
||||||
|
repeater: a,
|
||||||
|
).compareTo(
|
||||||
|
_scoreRepeater(
|
||||||
|
senderLocation: senderLocation,
|
||||||
|
recipientLocation: recipientLatLng,
|
||||||
|
repeater: b,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final selected = <Contact>[];
|
||||||
|
var currentPoint = senderLocation;
|
||||||
|
var currentDistanceToRecipient = _distance.as(
|
||||||
|
LengthUnit.Meter,
|
||||||
|
senderLocation,
|
||||||
|
recipientLatLng,
|
||||||
|
);
|
||||||
|
final maxCorridorDistance = math.max(
|
||||||
|
1500.0,
|
||||||
|
currentDistanceToRecipient * 0.22,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final repeater in corridorRepeaters) {
|
||||||
|
if (selected.length >= 4) break;
|
||||||
|
final repeaterPoint = LatLng(
|
||||||
|
repeater.displayLocation!.latitude,
|
||||||
|
repeater.displayLocation!.longitude,
|
||||||
|
);
|
||||||
|
final distanceToSegment = _distanceToSegmentMeters(
|
||||||
|
repeaterPoint,
|
||||||
|
senderLocation,
|
||||||
|
recipientLatLng,
|
||||||
|
);
|
||||||
|
if (distanceToSegment > maxCorridorDistance) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final nextDistanceToRecipient = _distance.as(
|
||||||
|
LengthUnit.Meter,
|
||||||
|
repeaterPoint,
|
||||||
|
recipientLatLng,
|
||||||
|
);
|
||||||
|
if (nextDistanceToRecipient >= currentDistanceToRecipient - 300) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final distanceFromCurrent = _distance.as(
|
||||||
|
LengthUnit.Meter,
|
||||||
|
currentPoint,
|
||||||
|
repeaterPoint,
|
||||||
|
);
|
||||||
|
if (distanceFromCurrent < 100) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
selected.add(repeater);
|
||||||
|
currentPoint = repeaterPoint;
|
||||||
|
currentDistanceToRecipient = nextDistanceToRecipient;
|
||||||
|
|
||||||
|
if (currentDistanceToRecipient < 2500) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResolvedContactRoutePlan(
|
||||||
|
tokens: selected.map((contact) => _tokenFor(contact, hashSize)).toList(),
|
||||||
|
selectedContacts: selected,
|
||||||
|
summary: 'Resolved from repeater locations',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<Contact> _matchContactsForTokens(
|
||||||
|
List<String> tokens, {
|
||||||
|
required List<Contact> availableContacts,
|
||||||
|
}) {
|
||||||
|
final matches = <Contact>[];
|
||||||
|
final seen = <String>{};
|
||||||
|
for (final token in tokens) {
|
||||||
|
final match = availableContacts
|
||||||
|
.where(
|
||||||
|
(contact) => contact.publicKeyHex.toUpperCase().startsWith(token),
|
||||||
|
)
|
||||||
|
.firstOrNull;
|
||||||
|
if (match != null && seen.add(match.publicKeyHex)) {
|
||||||
|
matches.add(match);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> _dedupeTokens(List<String> tokens) {
|
||||||
|
final result = <String>[];
|
||||||
|
for (final token in tokens) {
|
||||||
|
if (result.isEmpty || result.last != token) {
|
||||||
|
result.add(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _tokenFor(Contact contact, int hashSize) {
|
||||||
|
final hex = contact.publicKeyHex.toUpperCase();
|
||||||
|
final length = hashSize * 2;
|
||||||
|
return hex.length < length ? hex : hex.substring(0, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _scoreRepeater({
|
||||||
|
required LatLng senderLocation,
|
||||||
|
required LatLng recipientLocation,
|
||||||
|
required Contact repeater,
|
||||||
|
}) {
|
||||||
|
final point = LatLng(
|
||||||
|
repeater.displayLocation!.latitude,
|
||||||
|
repeater.displayLocation!.longitude,
|
||||||
|
);
|
||||||
|
final toRecipient = _distance.as(
|
||||||
|
LengthUnit.Meter,
|
||||||
|
point,
|
||||||
|
recipientLocation,
|
||||||
|
);
|
||||||
|
final corridor = _distanceToSegmentMeters(
|
||||||
|
point,
|
||||||
|
senderLocation,
|
||||||
|
recipientLocation,
|
||||||
|
);
|
||||||
|
return (toRecipient * 0.75) + (corridor * 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _scoreKnownRouteRepeater({
|
||||||
|
required LatLng senderLocation,
|
||||||
|
required LatLng recipientLocation,
|
||||||
|
required Contact repeater,
|
||||||
|
required List<Contact> availableContacts,
|
||||||
|
required int hashSize,
|
||||||
|
}) {
|
||||||
|
final baseScore = _scoreRepeater(
|
||||||
|
senderLocation: senderLocation,
|
||||||
|
recipientLocation: recipientLocation,
|
||||||
|
repeater: repeater,
|
||||||
|
);
|
||||||
|
final repeaterPoint = LatLng(
|
||||||
|
repeater.displayLocation!.latitude,
|
||||||
|
repeater.displayLocation!.longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
final chainContacts = repeater.routeCanonicalText
|
||||||
|
.split(',')
|
||||||
|
.where((token) => token.isNotEmpty)
|
||||||
|
.map(
|
||||||
|
(token) => availableContacts
|
||||||
|
.where(
|
||||||
|
(contact) =>
|
||||||
|
contact.displayLocation != null &&
|
||||||
|
contact.publicKeyHex.toUpperCase().startsWith(
|
||||||
|
token.toUpperCase(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.firstOrNull,
|
||||||
|
)
|
||||||
|
.whereType<Contact>()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final chainEnd = chainContacts.isNotEmpty
|
||||||
|
? LatLng(
|
||||||
|
chainContacts.last.displayLocation!.latitude,
|
||||||
|
chainContacts.last.displayLocation!.longitude,
|
||||||
|
)
|
||||||
|
: senderLocation;
|
||||||
|
final chainGap = _distance.as(LengthUnit.Meter, chainEnd, repeaterPoint);
|
||||||
|
final senderGap = _distance.as(
|
||||||
|
LengthUnit.Meter,
|
||||||
|
senderLocation,
|
||||||
|
repeaterPoint,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (chainGap * 0.55) + (baseScore * 0.35) + (senderGap * 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _progressAlongSegment({
|
||||||
|
required LatLng point,
|
||||||
|
required LatLng start,
|
||||||
|
required LatLng end,
|
||||||
|
}) {
|
||||||
|
final dx = end.longitude - start.longitude;
|
||||||
|
final dy = end.latitude - start.latitude;
|
||||||
|
final lengthSquared = (dx * dx) + (dy * dy);
|
||||||
|
if (lengthSquared == 0) return 0;
|
||||||
|
return (((point.longitude - start.longitude) * dx) +
|
||||||
|
((point.latitude - start.latitude) * dy)) /
|
||||||
|
lengthSquared;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _distanceToSegmentMeters(LatLng p, LatLng a, LatLng b) {
|
||||||
|
final ax = a.longitude;
|
||||||
|
final ay = a.latitude;
|
||||||
|
final bx = b.longitude;
|
||||||
|
final by = b.latitude;
|
||||||
|
final px = p.longitude;
|
||||||
|
final py = p.latitude;
|
||||||
|
|
||||||
|
final abx = bx - ax;
|
||||||
|
final aby = by - ay;
|
||||||
|
final apx = px - ax;
|
||||||
|
final apy = py - ay;
|
||||||
|
final ab2 = abx * abx + aby * aby;
|
||||||
|
if (ab2 == 0) {
|
||||||
|
return _distance.as(LengthUnit.Meter, a, p);
|
||||||
|
}
|
||||||
|
var t = (apx * abx + apy * aby) / ab2;
|
||||||
|
t = t.clamp(0.0, 1.0);
|
||||||
|
final closest = LatLng(ay + aby * t, ax + abx * t);
|
||||||
|
return _distance.as(LengthUnit.Meter, closest, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,38 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import '../../models/contact.dart';
|
import '../../models/contact.dart';
|
||||||
|
import '../../providers/connection_provider.dart';
|
||||||
import '../../providers/app_provider.dart';
|
import '../../providers/app_provider.dart';
|
||||||
|
import '../../services/contact_route_resolver.dart';
|
||||||
import '../../services/route_hash_preferences.dart';
|
import '../../services/route_hash_preferences.dart';
|
||||||
|
|
||||||
class ContactRouteDialogResult {
|
class ContactRouteDialogResult {
|
||||||
final ParsedContactRoute? route;
|
final ParsedContactRoute? route;
|
||||||
final bool shouldClear;
|
final bool shouldClear;
|
||||||
|
final LatLng? inferredFallbackLocation;
|
||||||
|
|
||||||
const ContactRouteDialogResult._({this.route, required this.shouldClear});
|
const ContactRouteDialogResult._({
|
||||||
|
this.route,
|
||||||
|
required this.shouldClear,
|
||||||
|
this.inferredFallbackLocation,
|
||||||
|
});
|
||||||
|
|
||||||
const ContactRouteDialogResult.set(ParsedContactRoute route)
|
const ContactRouteDialogResult.set(ParsedContactRoute route)
|
||||||
: this._(route: route, shouldClear: false);
|
: this._(route: route, shouldClear: false);
|
||||||
|
|
||||||
|
const ContactRouteDialogResult.setWithFallback(
|
||||||
|
ParsedContactRoute route, {
|
||||||
|
LatLng? inferredFallbackLocation,
|
||||||
|
}) : this._(
|
||||||
|
route: route,
|
||||||
|
shouldClear: false,
|
||||||
|
inferredFallbackLocation: inferredFallbackLocation,
|
||||||
|
);
|
||||||
|
|
||||||
const ContactRouteDialogResult.clear() : this._(shouldClear: true);
|
const ContactRouteDialogResult.clear() : this._(shouldClear: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +79,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
ParsedContactRoute? _parsedRoute;
|
ParsedContactRoute? _parsedRoute;
|
||||||
String? _errorText;
|
String? _errorText;
|
||||||
bool _showRoutingInfo = false;
|
bool _showRoutingInfo = false;
|
||||||
|
List<Contact> _selectedMapHops = const [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -107,6 +127,36 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<Contact> get _routeCandidates =>
|
||||||
|
widget.availableContacts
|
||||||
|
.where(
|
||||||
|
(contact) => contact.isRepeater && contact.displayLocation != null,
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => a.displayName.compareTo(b.displayName));
|
||||||
|
|
||||||
|
void _syncMapSelectionFromController() {
|
||||||
|
final tokens = _controller.text
|
||||||
|
.trim()
|
||||||
|
.split(',')
|
||||||
|
.map((token) => token.trim().toUpperCase())
|
||||||
|
.where((token) => token.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
final selected = <Contact>[];
|
||||||
|
final seen = <String>{};
|
||||||
|
for (final token in tokens) {
|
||||||
|
final match = _routeCandidates
|
||||||
|
.where(
|
||||||
|
(contact) => contact.publicKeyHex.toUpperCase().startsWith(token),
|
||||||
|
)
|
||||||
|
.firstOrNull;
|
||||||
|
if (match != null && seen.add(match.publicKeyHex)) {
|
||||||
|
selected.add(match);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_selectedMapHops = selected;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadHashSizePreference() async {
|
Future<void> _loadHashSizePreference() async {
|
||||||
final hashSize = await RouteHashPreferences.getHashSize();
|
final hashSize = await RouteHashPreferences.getHashSize();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -114,6 +164,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
_selectedHashSize = hashSize;
|
_selectedHashSize = hashSize;
|
||||||
});
|
});
|
||||||
_reparse();
|
_reparse();
|
||||||
|
_syncMapSelectionFromController();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _tokenFor(Contact contact, int hashSize) {
|
String _tokenFor(Contact contact, int hashSize) {
|
||||||
@@ -125,23 +176,168 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
return hex.substring(0, length);
|
return hex.substring(0, length);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _appendHop(Contact contact) {
|
void _syncControllerFromSelectedHops() {
|
||||||
final token = _tokenFor(contact, _selectedHashSize);
|
final tokens = _selectedMapHops
|
||||||
final current = _controller.text.trim();
|
.map((contact) => _tokenFor(contact, _selectedHashSize))
|
||||||
_controller.text = current.isEmpty ? token : '$current,$token';
|
.toList();
|
||||||
|
_controller.text = tokens.join(',');
|
||||||
_controller.selection = TextSelection.fromPosition(
|
_controller.selection = TextSelection.fromPosition(
|
||||||
TextPosition(offset: _controller.text.length),
|
TextPosition(offset: _controller.text.length),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _toggleHop(Contact contact) {
|
||||||
|
setState(() {
|
||||||
|
if (_selectedMapHops.any(
|
||||||
|
(item) => item.publicKeyHex == contact.publicKeyHex,
|
||||||
|
)) {
|
||||||
|
_selectedMapHops = _selectedMapHops
|
||||||
|
.where((item) => item.publicKeyHex != contact.publicKeyHex)
|
||||||
|
.toList();
|
||||||
|
} else {
|
||||||
|
_selectedMapHops = [..._selectedMapHops, contact];
|
||||||
|
}
|
||||||
|
_syncControllerFromSelectedHops();
|
||||||
|
_reparse();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyResolvedPlan(ResolvedContactRoutePlan plan) {
|
||||||
|
setState(() {
|
||||||
|
_selectedMapHops = plan.selectedContacts;
|
||||||
|
_controller.text = plan.canonicalText;
|
||||||
|
_controller.selection = TextSelection.fromPosition(
|
||||||
|
TextPosition(offset: _controller.text.length),
|
||||||
|
);
|
||||||
|
_errorText = null;
|
||||||
|
});
|
||||||
|
_reparse();
|
||||||
|
}
|
||||||
|
|
||||||
|
LatLng? _resolveLastHopLocation() {
|
||||||
|
if (_selectedMapHops.isNotEmpty) {
|
||||||
|
return _selectedMapHops.last.displayLocation == null
|
||||||
|
? null
|
||||||
|
: LatLng(
|
||||||
|
_selectedMapHops.last.displayLocation!.latitude,
|
||||||
|
_selectedMapHops.last.displayLocation!.longitude,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final tokens = _controller.text
|
||||||
|
.trim()
|
||||||
|
.split(',')
|
||||||
|
.map((token) => token.trim().toUpperCase())
|
||||||
|
.where((token) => token.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
if (tokens.isEmpty) return null;
|
||||||
|
final lastToken = tokens.last;
|
||||||
|
final match = _routeCandidates
|
||||||
|
.where(
|
||||||
|
(contact) => contact.publicKeyHex.toUpperCase().startsWith(lastToken),
|
||||||
|
)
|
||||||
|
.firstOrNull;
|
||||||
|
final location = match?.displayLocation;
|
||||||
|
if (location == null) return null;
|
||||||
|
return LatLng(location.latitude, location.longitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
LatLng? _buildSyntheticFallbackLocation() {
|
||||||
|
final lastHopLocation = _resolveLastHopLocation();
|
||||||
|
if (lastHopLocation == null) return null;
|
||||||
|
|
||||||
|
final seed = widget.contact.publicKey.fold<int>(
|
||||||
|
_controller.text.codeUnits.fold<int>(0, (sum, unit) => sum + unit),
|
||||||
|
(sum, byte) => sum + byte,
|
||||||
|
);
|
||||||
|
final angle = (seed % 360) * (math.pi / 180.0);
|
||||||
|
const radiusMeters = 500.0;
|
||||||
|
final latOffset = (radiusMeters / 111320.0) * math.cos(angle);
|
||||||
|
final lonDenominator =
|
||||||
|
111320.0 * math.cos(lastHopLocation.latitude * (math.pi / 180.0));
|
||||||
|
final lonOffset = lonDenominator.abs() < 1e-6
|
||||||
|
? 0.0
|
||||||
|
: (radiusMeters / lonDenominator) * math.sin(angle);
|
||||||
|
return LatLng(
|
||||||
|
lastHopLocation.latitude + latOffset,
|
||||||
|
lastHopLocation.longitude + lonOffset,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _resolvePathAutomatically() {
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final advLat = connectionProvider.deviceInfo.advLat;
|
||||||
|
final advLon = connectionProvider.deviceInfo.advLon;
|
||||||
|
final recipientLocation = widget.contact.displayLocation;
|
||||||
|
if (advLat == null ||
|
||||||
|
advLon == null ||
|
||||||
|
(advLat == 0 && advLon == 0) ||
|
||||||
|
recipientLocation == null) {
|
||||||
|
setState(() {
|
||||||
|
_errorText =
|
||||||
|
'Automatic resolve needs both your advertised location and the contact location.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final plan = ContactRouteResolver.resolveAutomaticRoute(
|
||||||
|
senderLocation: LatLng(advLat / 1e6, advLon / 1e6),
|
||||||
|
recipient: widget.contact,
|
||||||
|
availableContacts: widget.availableContacts,
|
||||||
|
hashSize: _selectedHashSize,
|
||||||
|
);
|
||||||
|
if (plan == null) {
|
||||||
|
setState(() {
|
||||||
|
_errorText =
|
||||||
|
'Could not resolve a route from available repeater locations.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyResolvedPlan(plan);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final appProvider = context.watch<AppProvider>();
|
final appProvider = context.watch<AppProvider>();
|
||||||
final routeCandidates =
|
final routeCandidates = _routeCandidates;
|
||||||
widget.availableContacts
|
final connectionProvider = context.watch<ConnectionProvider>();
|
||||||
.where((contact) => contact.isRepeater || contact.isRoom)
|
final selfPoint =
|
||||||
.toList()
|
connectionProvider.deviceInfo.advLat != null &&
|
||||||
..sort((a, b) => a.displayName.compareTo(b.displayName));
|
connectionProvider.deviceInfo.advLon != null &&
|
||||||
|
!(connectionProvider.deviceInfo.advLat == 0 &&
|
||||||
|
connectionProvider.deviceInfo.advLon == 0)
|
||||||
|
? LatLng(
|
||||||
|
connectionProvider.deviceInfo.advLat! / 1e6,
|
||||||
|
connectionProvider.deviceInfo.advLon! / 1e6,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
final recipientLocation = widget.contact.displayLocation;
|
||||||
|
final recipientPoint = recipientLocation == null
|
||||||
|
? null
|
||||||
|
: LatLng(recipientLocation.latitude, recipientLocation.longitude);
|
||||||
|
final routePoints = <LatLng>[
|
||||||
|
...?selfPoint == null ? null : [selfPoint],
|
||||||
|
..._selectedMapHops
|
||||||
|
.where((contact) => contact.displayLocation != null)
|
||||||
|
.map(
|
||||||
|
(contact) => LatLng(
|
||||||
|
contact.displayLocation!.latitude,
|
||||||
|
contact.displayLocation!.longitude,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...?recipientPoint == null ? null : [recipientPoint],
|
||||||
|
];
|
||||||
|
final mapPoints = <LatLng>[
|
||||||
|
...?selfPoint == null ? null : [selfPoint],
|
||||||
|
...?recipientPoint == null ? null : [recipientPoint],
|
||||||
|
...routeCandidates.map(
|
||||||
|
(contact) => LatLng(
|
||||||
|
contact.displayLocation!.latitude,
|
||||||
|
contact.displayLocation!.longitude,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
return FractionallySizedBox(
|
return FractionallySizedBox(
|
||||||
heightFactor: 0.85,
|
heightFactor: 0.85,
|
||||||
@@ -155,6 +351,11 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
style: Theme.of(context).textTheme.headlineSmall,
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
TextField(
|
TextField(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
textCapitalization: TextCapitalization.characters,
|
textCapitalization: TextCapitalization.characters,
|
||||||
@@ -182,12 +383,139 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
SelectableText(
|
SelectableText(
|
||||||
_parsedRoute!.canonicalText,
|
_parsedRoute!.canonicalText,
|
||||||
style: Theme.of(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
context,
|
fontFamily: 'monospace',
|
||||||
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _resolvePathAutomatically,
|
||||||
|
icon: const Icon(Icons.auto_fix_high),
|
||||||
|
label: const Text('Resolve Path'),
|
||||||
|
),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 260),
|
||||||
|
child: Text(
|
||||||
|
'Tap repeaters on the map to build the path.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
height: 260,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).dividerColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: mapPoints.length < 2
|
||||||
|
? const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Text(
|
||||||
|
'Map path builder needs your advertised location, the contact location, and visible repeater locations.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: flutter_map.FlutterMap(
|
||||||
|
options: flutter_map.MapOptions(
|
||||||
|
initialCameraFit:
|
||||||
|
flutter_map.CameraFit.bounds(
|
||||||
|
bounds:
|
||||||
|
flutter_map
|
||||||
|
.LatLngBounds.fromPoints(
|
||||||
|
mapPoints,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
flutter_map.TileLayer(
|
||||||
|
urlTemplate:
|
||||||
|
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
userAgentPackageName: 'com.meshcore.sar',
|
||||||
|
),
|
||||||
|
if (routePoints.length >= 2)
|
||||||
|
flutter_map.PolylineLayer(
|
||||||
|
polylines: [
|
||||||
|
flutter_map.Polyline(
|
||||||
|
points: routePoints,
|
||||||
|
strokeWidth: 4,
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
flutter_map.MarkerLayer(
|
||||||
|
markers: [
|
||||||
|
...routeCandidates.map((candidate) {
|
||||||
|
final isSelected = _selectedMapHops
|
||||||
|
.any(
|
||||||
|
(item) =>
|
||||||
|
item.publicKeyHex ==
|
||||||
|
candidate.publicKeyHex,
|
||||||
|
);
|
||||||
|
return flutter_map.Marker(
|
||||||
|
point: LatLng(
|
||||||
|
candidate
|
||||||
|
.displayLocation!
|
||||||
|
.latitude,
|
||||||
|
candidate
|
||||||
|
.displayLocation!
|
||||||
|
.longitude,
|
||||||
|
),
|
||||||
|
width: 64,
|
||||||
|
height: 70,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () =>
|
||||||
|
_toggleHop(candidate),
|
||||||
|
child: _RouteMarkerDot(
|
||||||
|
label: _tokenFor(
|
||||||
|
candidate,
|
||||||
|
_selectedHashSize,
|
||||||
|
),
|
||||||
|
color: isSelected
|
||||||
|
? Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary
|
||||||
|
: Colors.blueGrey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (_selectedMapHops.isNotEmpty)
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: _selectedMapHops.map((contact) {
|
||||||
|
return InputChip(
|
||||||
|
label: Text(contact.displayName),
|
||||||
|
onDeleted: () => _toggleHop(contact),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
_AutomationRoutingInfo(
|
_AutomationRoutingInfo(
|
||||||
isExpanded: _showRoutingInfo,
|
isExpanded: _showRoutingInfo,
|
||||||
onToggle: () {
|
onToggle: () {
|
||||||
@@ -195,43 +523,21 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
_showRoutingInfo = !_showRoutingInfo;
|
_showRoutingInfo = !_showRoutingInfo;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
autoRouteRotationEnabled: appProvider.autoRouteRotationEnabled,
|
autoRouteRotationEnabled:
|
||||||
|
appProvider.autoRouteRotationEnabled,
|
||||||
nearestRelayFallbackEnabled:
|
nearestRelayFallbackEnabled:
|
||||||
appProvider.nearestRelayFallbackEnabled,
|
appProvider.nearestRelayFallbackEnabled,
|
||||||
clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry,
|
clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
],
|
||||||
'Pick hops from contacts',
|
|
||||||
style: Theme.of(context).textTheme.labelLarge,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
if (routeCandidates.isEmpty)
|
|
||||||
const Text(
|
|
||||||
'No repeater or room contacts are available for route building.',
|
|
||||||
)
|
|
||||||
else
|
|
||||||
Expanded(
|
|
||||||
child: ListView.builder(
|
|
||||||
itemCount: routeCandidates.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final candidate = routeCandidates[index];
|
|
||||||
return ListTile(
|
|
||||||
dense: true,
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: Text(candidate.displayName),
|
|
||||||
trailing: TextButton(
|
|
||||||
onPressed: () => _appendHop(candidate),
|
|
||||||
child: Text(
|
|
||||||
'Use ${_tokenFor(candidate, _selectedHashSize)}',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
OverflowBar(
|
||||||
const SizedBox(height: 16),
|
alignment: MainAxisAlignment.spaceBetween,
|
||||||
Row(
|
spacing: 8,
|
||||||
|
overflowSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
@@ -244,13 +550,16 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
).pop(const ContactRouteDialogResult.clear()),
|
).pop(const ContactRouteDialogResult.clear()),
|
||||||
child: const Text('Clear Route'),
|
child: const Text('Clear Route'),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: _parsedRoute == null
|
onPressed: _parsedRoute == null
|
||||||
? null
|
? null
|
||||||
: () => Navigator.of(
|
: () => Navigator.of(context).pop(
|
||||||
context,
|
ContactRouteDialogResult.setWithFallback(
|
||||||
).pop(ContactRouteDialogResult.set(_parsedRoute!)),
|
_parsedRoute!,
|
||||||
|
inferredFallbackLocation:
|
||||||
|
_buildSyntheticFallbackLocation(),
|
||||||
|
),
|
||||||
|
),
|
||||||
child: const Text('Set Route'),
|
child: const Text('Set Route'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -262,6 +571,31 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _RouteMarkerDot extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
const _RouteMarkerDot({required this.label, required this.color});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Tooltip(
|
||||||
|
message: label,
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _AutomationRoutingInfo extends StatelessWidget {
|
class _AutomationRoutingInfo extends StatelessWidget {
|
||||||
final bool isExpanded;
|
final bool isExpanded;
|
||||||
final VoidCallback onToggle;
|
final VoidCallback onToggle;
|
||||||
|
|||||||
@@ -128,6 +128,24 @@ class ContactTile extends StatelessWidget {
|
|||||||
final Widget subtitleWidget = Column(
|
final Widget subtitleWidget = Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 6,
|
||||||
|
children: [
|
||||||
|
_buildMetaPill(
|
||||||
|
context,
|
||||||
|
icon: _contactTypeIcon(contact),
|
||||||
|
label: contact.type.displayName,
|
||||||
|
),
|
||||||
|
_buildMetaPill(
|
||||||
|
context,
|
||||||
|
icon: Icons.key_outlined,
|
||||||
|
label: contact.publicKeyShort,
|
||||||
|
monospace: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
if (location != null) ...[
|
if (location != null) ...[
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
_buildLocationLine(
|
_buildLocationLine(
|
||||||
@@ -591,6 +609,7 @@ class ContactTile extends StatelessWidget {
|
|||||||
contact.publicKey,
|
contact.publicKey,
|
||||||
signedEncodedPathLen: parsedRoute.signedEncodedPathLen,
|
signedEncodedPathLen: parsedRoute.signedEncodedPathLen,
|
||||||
paddedPathBytes: parsedRoute.paddedPathBytes,
|
paddedPathBytes: parsedRoute.paddedPathBytes,
|
||||||
|
inferredFallbackLocation: routeResult.inferredFallbackLocation,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -671,6 +690,53 @@ class ContactTile extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildMetaPill(
|
||||||
|
BuildContext context, {
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
bool monospace = false,
|
||||||
|
}) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.75),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 12, color: colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontFamily: monospace ? 'monospace' : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _contactTypeIcon(Contact contact) {
|
||||||
|
switch (contact.type) {
|
||||||
|
case ContactType.chat:
|
||||||
|
return Icons.person_outline;
|
||||||
|
case ContactType.repeater:
|
||||||
|
return Icons.router_outlined;
|
||||||
|
case ContactType.room:
|
||||||
|
return Icons.meeting_room_outlined;
|
||||||
|
case ContactType.channel:
|
||||||
|
return Icons.campaign_outlined;
|
||||||
|
case ContactType.none:
|
||||||
|
return Icons.help_outline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildLocationLine(
|
Widget _buildLocationLine(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required double latitude,
|
required double latitude,
|
||||||
|
|||||||
@@ -1788,6 +1788,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
receptionDetails?.rssiDbm ??
|
receptionDetails?.rssiDbm ??
|
||||||
matchedRxLog?.logRxDataInfo?.rssiDbm ??
|
matchedRxLog?.logRxDataInfo?.rssiDbm ??
|
||||||
message.lastEchoRssiDbm;
|
message.lastEchoRssiDbm;
|
||||||
|
final routeMetadata = messagesProvider.getMessageRouteMetadata(message.id);
|
||||||
|
|
||||||
// Look up contact information for rich display name
|
// Look up contact information for rich display name
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
@@ -2515,7 +2516,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
switch (recipient.deliveryStatus) {
|
switch (recipient.deliveryStatus) {
|
||||||
case MessageDeliveryStatus.delivered:
|
case MessageDeliveryStatus.delivered:
|
||||||
statusColor = Colors.green;
|
statusColor = Colors.green;
|
||||||
statusIcon = Icons.check_circle;
|
statusIcon = Icons.done_all;
|
||||||
statusText =
|
statusText =
|
||||||
recipient.roundTripTimeMs != null
|
recipient.roundTripTimeMs != null
|
||||||
? '${recipient.roundTripTimeMs}ms'
|
? '${recipient.roundTripTimeMs}ms'
|
||||||
@@ -2523,6 +2524,15 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
context,
|
context,
|
||||||
)!.delivered;
|
)!.delivered;
|
||||||
break;
|
break;
|
||||||
|
case MessageDeliveryStatus.sent:
|
||||||
|
statusColor = Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurfaceVariant;
|
||||||
|
statusIcon = Icons.done;
|
||||||
|
statusText = AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.sent;
|
||||||
|
break;
|
||||||
case MessageDeliveryStatus.failed:
|
case MessageDeliveryStatus.failed:
|
||||||
statusColor = Colors.red;
|
statusColor = Colors.red;
|
||||||
statusIcon = Icons.cancel;
|
statusIcon = Icons.cancel;
|
||||||
@@ -2531,7 +2541,6 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
)!.failed;
|
)!.failed;
|
||||||
break;
|
break;
|
||||||
case MessageDeliveryStatus.sending:
|
case MessageDeliveryStatus.sending:
|
||||||
case MessageDeliveryStatus.sent:
|
|
||||||
default:
|
default:
|
||||||
statusColor = Colors.orange;
|
statusColor = Colors.orange;
|
||||||
statusIcon = Icons.schedule;
|
statusIcon = Icons.schedule;
|
||||||
@@ -2726,6 +2735,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
context,
|
context,
|
||||||
message: message,
|
message: message,
|
||||||
isSarMarker: isSarMarker,
|
isSarMarker: isSarMarker,
|
||||||
|
routeMetadata: routeMetadata,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../../models/contact.dart';
|
import '../../models/contact.dart';
|
||||||
import '../../models/message.dart';
|
import '../../models/message.dart';
|
||||||
|
import '../../models/message_route_metadata.dart';
|
||||||
import '../../utils/avatar_label_helper.dart';
|
import '../../utils/avatar_label_helper.dart';
|
||||||
import '../../utils/message_extensions.dart';
|
import '../../utils/message_extensions.dart';
|
||||||
import '../common/contact_avatar.dart';
|
import '../common/contact_avatar.dart';
|
||||||
@@ -59,6 +60,7 @@ Widget buildBubbleMetaFooter(
|
|||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required Message message,
|
required Message message,
|
||||||
required bool isSarMarker,
|
required bool isSarMarker,
|
||||||
|
MessageRouteMetadata? routeMetadata,
|
||||||
}) {
|
}) {
|
||||||
final metaColor = Theme.of(
|
final metaColor = Theme.of(
|
||||||
context,
|
context,
|
||||||
@@ -86,12 +88,13 @@ Widget buildBubbleMetaFooter(
|
|||||||
).textTheme.labelSmall?.copyWith(color: metaColor),
|
).textTheme.labelSmall?.copyWith(color: metaColor),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
} else if (!isSarMarker && message.pathLen < 255) {
|
} else if (!isSarMarker && _effectivePathLen(message, routeMetadata) < 255) {
|
||||||
|
final effectivePathLen = _effectivePathLen(message, routeMetadata);
|
||||||
items.addAll([
|
items.addAll([
|
||||||
Icon(Icons.alt_route, size: 11, color: metaColor),
|
Icon(Icons.alt_route, size: 11, color: metaColor),
|
||||||
const SizedBox(width: 3),
|
const SizedBox(width: 3),
|
||||||
Text(
|
Text(
|
||||||
message.pathLen == 0 ? 'direct' : '${message.pathLen}hop',
|
effectivePathLen == 0 ? 'direct' : '${effectivePathLen}hop',
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.labelSmall?.copyWith(color: metaColor),
|
).textTheme.labelSmall?.copyWith(color: metaColor),
|
||||||
@@ -124,6 +127,9 @@ Widget buildBubbleMetaFooter(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _effectivePathLen(Message message, MessageRouteMetadata? routeMetadata) =>
|
||||||
|
routeMetadata?.hopCount ?? message.pathLen;
|
||||||
|
|
||||||
Widget buildChannelHeaderPill(
|
Widget buildChannelHeaderPill(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required String label,
|
required String label,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../../models/message.dart';
|
import '../../models/message.dart';
|
||||||
|
import '../../models/message_route_metadata.dart';
|
||||||
import '../../models/path_selection.dart';
|
import '../../models/path_selection.dart';
|
||||||
import '../../models/message_reception_details.dart';
|
import '../../models/message_reception_details.dart';
|
||||||
import '../../providers/messages_provider.dart';
|
import '../../providers/messages_provider.dart';
|
||||||
@@ -209,7 +210,7 @@ Widget buildSentDirectSignalStatus(
|
|||||||
_techChip(
|
_techChip(
|
||||||
context,
|
context,
|
||||||
icon: Icons.alt_route,
|
icon: Icons.alt_route,
|
||||||
label: hopDisplayLabel(message),
|
label: hopDisplayLabelForMessage(message, routeMetadata),
|
||||||
color: Colors.indigo,
|
color: Colors.indigo,
|
||||||
),
|
),
|
||||||
_techChip(
|
_techChip(
|
||||||
@@ -294,6 +295,17 @@ String hopDisplayLabel(Message message) {
|
|||||||
return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}';
|
return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String hopDisplayLabelForMessage(
|
||||||
|
Message message,
|
||||||
|
MessageRouteMetadata? routeMetadata,
|
||||||
|
) {
|
||||||
|
final effectivePathLen = routeMetadata?.hopCount ?? message.pathLen;
|
||||||
|
if (effectivePathLen == 0) return 'Direct';
|
||||||
|
if (effectivePathLen >= 255 && message.isContactMessage) return 'Direct';
|
||||||
|
if (effectivePathLen >= 255) return 'Unknown';
|
||||||
|
return '$effectivePathLen hop${effectivePathLen == 1 ? '' : 's'}';
|
||||||
|
}
|
||||||
|
|
||||||
Widget _techChip(
|
Widget _techChip(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
|
|||||||
@@ -458,6 +458,24 @@ void main() {
|
|||||||
expect(updated.routeCanonicalText, 'AABB,CCDD');
|
expect(updated.routeCanonicalText, 'AABB,CCDD');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('stores inferred fallback gps when route is set locally', () {
|
||||||
|
final route = ContactRouteCodec.parse('AABB,CCDD');
|
||||||
|
const fallback = LatLng(46.1001, 14.5002);
|
||||||
|
|
||||||
|
provider.setContactRouteLocal(
|
||||||
|
publicKey,
|
||||||
|
signedEncodedPathLen: route.signedEncodedPathLen,
|
||||||
|
paddedPathBytes: route.paddedPathBytes,
|
||||||
|
inferredFallbackLocation: fallback,
|
||||||
|
);
|
||||||
|
|
||||||
|
final updated = provider.findContactByKey(publicKey)!;
|
||||||
|
expect(updated.displayLocation, isNotNull);
|
||||||
|
expect(updated.displayLocation!.latitude, closeTo(46.1001, 0.000001));
|
||||||
|
expect(updated.displayLocation!.longitude, closeTo(14.5002, 0.000001));
|
||||||
|
expect(updated.advertHistory, isNotEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
test('resetContactRouteLocal clears route state', () {
|
test('resetContactRouteLocal clears route state', () {
|
||||||
final route = ContactRouteCodec.parse('AA,BB,CC');
|
final route = ContactRouteCodec.parse('AA,BB,CC');
|
||||||
provider.setContactRouteLocal(
|
provider.setContactRouteLocal(
|
||||||
@@ -576,7 +594,9 @@ void main() {
|
|||||||
expect(distanceMeters, closeTo(100.0, 8.0));
|
expect(distanceMeters, closeTo(100.0, 8.0));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('does not infer a fallback location when the contact advertises one', () {
|
test(
|
||||||
|
'does not infer a fallback location when the contact advertises one',
|
||||||
|
() {
|
||||||
final repeaterKey = Uint8List.fromList([
|
final repeaterKey = Uint8List.fromList([
|
||||||
0xCC,
|
0xCC,
|
||||||
0xDD,
|
0xDD,
|
||||||
@@ -615,7 +635,8 @@ void main() {
|
|||||||
expect(updated.displayLocation, isNotNull);
|
expect(updated.displayLocation, isNotNull);
|
||||||
expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.000001));
|
expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.000001));
|
||||||
expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.000001));
|
expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.000001));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
group('ContactsProvider.updateFastGps', () {
|
group('ContactsProvider.updateFastGps', () {
|
||||||
|
|||||||
143
test/services/contact_route_resolver_test.dart
Normal file
143
test/services/contact_route_resolver_test.dart
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/contact.dart';
|
||||||
|
import 'package:meshcore_sar_app/services/contact_route_resolver.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('prefers known repeater route when available', () {
|
||||||
|
final recipient = _contact(
|
||||||
|
name: 'Target',
|
||||||
|
type: ContactType.chat,
|
||||||
|
seed: 90,
|
||||||
|
lat: 46.20,
|
||||||
|
lon: 14.70,
|
||||||
|
);
|
||||||
|
final routedRepeater = _contact(
|
||||||
|
name: 'Routed',
|
||||||
|
type: ContactType.repeater,
|
||||||
|
seed: 10,
|
||||||
|
lat: 46.15,
|
||||||
|
lon: 14.60,
|
||||||
|
signedPathLen: ContactRouteCodec.toSignedDescriptor(1),
|
||||||
|
outPath: Uint8List.fromList([0xAA]),
|
||||||
|
);
|
||||||
|
|
||||||
|
final plan = ContactRouteResolver.resolveAutomaticRoute(
|
||||||
|
senderLocation: const LatLng(46.00, 14.50),
|
||||||
|
recipient: recipient,
|
||||||
|
availableContacts: [routedRepeater],
|
||||||
|
hashSize: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(plan, isNotNull);
|
||||||
|
expect(plan!.tokens, [
|
||||||
|
'AA',
|
||||||
|
routedRepeater.publicKeyHex.substring(0, 2).toUpperCase(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to location-only repeaters', () {
|
||||||
|
final recipient = _contact(
|
||||||
|
name: 'Target',
|
||||||
|
type: ContactType.chat,
|
||||||
|
seed: 90,
|
||||||
|
lat: 46.20,
|
||||||
|
lon: 14.70,
|
||||||
|
);
|
||||||
|
final nearRepeater = _contact(
|
||||||
|
name: 'Near',
|
||||||
|
type: ContactType.repeater,
|
||||||
|
seed: 10,
|
||||||
|
lat: 46.08,
|
||||||
|
lon: 14.55,
|
||||||
|
);
|
||||||
|
final farRepeater = _contact(
|
||||||
|
name: 'Far',
|
||||||
|
type: ContactType.repeater,
|
||||||
|
seed: 11,
|
||||||
|
lat: 46.40,
|
||||||
|
lon: 15.10,
|
||||||
|
);
|
||||||
|
|
||||||
|
final plan = ContactRouteResolver.resolveAutomaticRoute(
|
||||||
|
senderLocation: const LatLng(46.00, 14.50),
|
||||||
|
recipient: recipient,
|
||||||
|
availableContacts: [nearRepeater, farRepeater],
|
||||||
|
hashSize: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(plan, isNotNull);
|
||||||
|
expect(plan!.selectedContacts.first.displayName, 'Near');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('for known routes prefers anchor closest to the chain end', () {
|
||||||
|
final recipient = _contact(
|
||||||
|
name: 'Target',
|
||||||
|
type: ContactType.chat,
|
||||||
|
seed: 90,
|
||||||
|
lat: 46.20,
|
||||||
|
lon: 14.70,
|
||||||
|
);
|
||||||
|
final chainHop = _contact(
|
||||||
|
name: 'Chain Hop',
|
||||||
|
type: ContactType.repeater,
|
||||||
|
seed: 0xAA,
|
||||||
|
lat: 46.01,
|
||||||
|
lon: 14.51,
|
||||||
|
);
|
||||||
|
final nearAnchor = _contact(
|
||||||
|
name: 'Near Anchor',
|
||||||
|
type: ContactType.repeater,
|
||||||
|
seed: 10,
|
||||||
|
lat: 46.03,
|
||||||
|
lon: 14.53,
|
||||||
|
signedPathLen: ContactRouteCodec.toSignedDescriptor(1),
|
||||||
|
outPath: Uint8List.fromList([0xAA]),
|
||||||
|
);
|
||||||
|
final farAnchor = _contact(
|
||||||
|
name: 'Far Anchor',
|
||||||
|
type: ContactType.repeater,
|
||||||
|
seed: 11,
|
||||||
|
lat: 46.18,
|
||||||
|
lon: 14.68,
|
||||||
|
signedPathLen: ContactRouteCodec.toSignedDescriptor(1),
|
||||||
|
outPath: Uint8List.fromList([0xAA]),
|
||||||
|
);
|
||||||
|
|
||||||
|
final plan = ContactRouteResolver.resolveAutomaticRoute(
|
||||||
|
senderLocation: const LatLng(46.00, 14.50),
|
||||||
|
recipient: recipient,
|
||||||
|
availableContacts: [chainHop, nearAnchor, farAnchor],
|
||||||
|
hashSize: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(plan, isNotNull);
|
||||||
|
expect(plan!.selectedContacts.last.displayName, 'Near Anchor');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Contact _contact({
|
||||||
|
required String name,
|
||||||
|
required ContactType type,
|
||||||
|
required int seed,
|
||||||
|
required double lat,
|
||||||
|
required double lon,
|
||||||
|
int signedPathLen = -1,
|
||||||
|
Uint8List? outPath,
|
||||||
|
}) {
|
||||||
|
final publicKey = Uint8List(32)..fillRange(0, 32, seed);
|
||||||
|
return Contact(
|
||||||
|
publicKey: publicKey,
|
||||||
|
type: type,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: signedPathLen,
|
||||||
|
outPath: outPath ?? Uint8List(64),
|
||||||
|
advName: name,
|
||||||
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
advLat: (lat * 1e6).round(),
|
||||||
|
advLon: (lon * 1e6).round(),
|
||||||
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user