From abb73a4c653530821ec0faf07b7888f1641c07cb Mon Sep 17 00:00:00 2001 From: Janez T Date: Thu, 12 Mar 2026 14:39:44 +0100 Subject: [PATCH] Fix retry flow and contact filters --- lib/providers/app_provider.dart | 6 + lib/providers/connection_provider.dart | 2 + lib/providers/contacts_provider.dart | 16 +- lib/providers/messages_provider.dart | 6 + lib/screens/contacts_tab.dart | 80 ++- lib/services/contact_route_resolver.dart | 336 ++++++++++++ .../contacts/contact_route_dialog.dart | 508 +++++++++++++++--- lib/widgets/contacts/contact_tile.dart | 66 +++ lib/widgets/messages/message_bubble.dart | 14 +- .../messages/message_bubble_header.dart | 10 +- .../messages/message_bubble_signal.dart | 14 +- test/providers/contacts_provider_test.dart | 97 ++-- .../services/contact_route_resolver_test.dart | 143 +++++ 13 files changed, 1145 insertions(+), 153 deletions(-) create mode 100644 lib/services/contact_route_resolver.dart create mode 100644 test/services/contact_route_resolver_test.dart diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 123e916..41debd7 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -1293,6 +1293,9 @@ class AppProvider with ChangeNotifier { retryAttempt: retryAttempt, ); }; + connectionProvider.resolveContactForDmCallback = (contactPublicKey) { + return contactsProvider.findContactByKey(contactPublicKey); + }; messagesProvider.onFinalRouterFallbackCallback = ({required messageId, required contact, required message}) async { return _sendWithFinalNearestRouterFallback( @@ -1321,6 +1324,9 @@ class AppProvider with ChangeNotifier { roundTripTimeMs: roundTripTimeMs, ); }; + messagesProvider.onManualRetryPreparedCallback = (messageId) { + _directMessageRouteSessions.remove(messageId); + }; } Future _prepareDirectMessageSend({ diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 3e9af05..b9620bd 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -185,6 +185,7 @@ class ConnectionProvider with ChangeNotifier { onMessageEchoDetected; Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse; Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived; + Contact? Function(Uint8List contactPublicKey)? resolveContactForDmCallback; // Track pending send operations for auto-recovery final Map _pendingSendOperations = {}; @@ -1164,6 +1165,7 @@ class ConnectionProvider with ChangeNotifier { } var effectiveContact = contact; + effectiveContact ??= resolveContactForDmCallback?.call(contactPublicKey); if (messageId != null && effectiveContact != null && prepareDirectMessageSendCallback != null) { diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index 80cfde7..e06f41b 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -844,16 +844,30 @@ class ContactsProvider with ChangeNotifier { Uint8List publicKey, { required int signedEncodedPathLen, required Uint8List paddedPathBytes, + LatLng? inferredFallbackLocation, }) { final contact = findContactByKey(publicKey); if (contact == null) { return; } - _contacts[contact.publicKeyHex] = contact.copyWith( + var updatedContact = contact.copyWith( outPathLen: signedEncodedPathLen, 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(); notifyListeners(); } diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index c107a17..1d9c8cd 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -86,6 +86,7 @@ class MessagesProvider with ChangeNotifier { Future Function({required Contact contact, required int failureStreak})? onDirectPathFailedCallback; + void Function(String messageId)? onManualRetryPreparedCallback; Future Function({ required String messageId, required Contact contact, @@ -165,8 +166,12 @@ class MessagesProvider with ChangeNotifier { final index = _messages.indexWhere((message) => message.id == messageId); if (index != -1) { + final nextPathLen = selection.hopCount > 0 + ? selection.hopCount + : _messages[index].pathLen; _messages[index] = _messages[index].copyWith( usedFloodFallback: selection.usesFlood, + pathLen: nextPathLen, ); } @@ -2069,6 +2074,7 @@ class MessagesProvider with ChangeNotifier { _clearAckHistoryForMessage(messageId); _retryManager.clearRetry(messageId); _messageRouteMetadata.remove(messageId); + onManualRetryPreparedCallback?.call(messageId); _messages[index] = Message( id: message.id, diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index 9d8ca8b..b1a8c7b 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -32,6 +32,7 @@ class ContactsTab extends StatefulWidget { class _ContactsTabState extends State { Position? _currentPosition; final Set _resolvingAdvertKeys = {}; + bool _isResolvingPendingBatch = false; final Map _sortModes = { ContactSection.teamMembers: ContactSortMode.lastSeen, ContactSection.repeaters: ContactSortMode.lastSeen, @@ -95,6 +96,38 @@ class _ContactsTabState extends State { } } + void _schedulePendingAdvertResolution( + List 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 double _calculateDistanceInMeters( double lat1, @@ -230,6 +263,7 @@ class _ContactsTabState extends State { body: Consumer( builder: (context, contactsProvider, child) { final messagesProvider = context.watch(); + final connectionProvider = context.watch(); final chatContacts = _sortContacts( contactsProvider.chatContacts, ContactSection.teamMembers, @@ -248,6 +282,8 @@ class _ContactsTabState extends State { ); final pendingAdverts = contactsProvider.pendingAdverts; + _schedulePendingAdvertResolution(pendingAdverts, connectionProvider); + // Check if there are any displayable contacts final hasDisplayableContacts = chatContacts.isNotEmpty || @@ -287,27 +323,6 @@ class _ContactsTabState extends State { child: ListView( padding: const EdgeInsets.all(8), 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) if (chatContacts.isNotEmpty) ...[ _SectionHeader( @@ -347,6 +362,27 @@ class _ContactsTabState extends State { 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) _SectionHeader( title: l10n.channels, @@ -513,7 +549,7 @@ class _PendingAdvertTile extends StatelessWidget { ) : IconButton( icon: const Icon(Icons.person_add_alt_1), - tooltip: 'Quick add', + tooltip: 'Resolve contact', onPressed: onResolve, ), ), diff --git a/lib/services/contact_route_resolver.dart b/lib/services/contact_route_resolver.dart new file mode 100644 index 0000000..7f49407 --- /dev/null +++ b/lib/services/contact_route_resolver.dart @@ -0,0 +1,336 @@ +import 'dart:math' as math; + +import 'package:latlong2/latlong.dart'; + +import '../models/contact.dart'; + +class ResolvedContactRoutePlan { + final List tokens; + final List 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 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 = [ + ...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.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 = []; + 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 _matchContactsForTokens( + List tokens, { + required List availableContacts, + }) { + final matches = []; + final seen = {}; + 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 _dedupeTokens(List tokens) { + final result = []; + 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 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() + .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); + } +} diff --git a/lib/widgets/contacts/contact_route_dialog.dart b/lib/widgets/contacts/contact_route_dialog.dart index e9b80af..80db51d 100644 --- a/lib/widgets/contacts/contact_route_dialog.dart +++ b/lib/widgets/contacts/contact_route_dialog.dart @@ -1,19 +1,38 @@ 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 'dart:math' as math; import '../../models/contact.dart'; +import '../../providers/connection_provider.dart'; import '../../providers/app_provider.dart'; +import '../../services/contact_route_resolver.dart'; import '../../services/route_hash_preferences.dart'; class ContactRouteDialogResult { final ParsedContactRoute? route; 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) : 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); } @@ -60,6 +79,7 @@ class _ContactRouteDialogState extends State { ParsedContactRoute? _parsedRoute; String? _errorText; bool _showRoutingInfo = false; + List _selectedMapHops = const []; @override void initState() { @@ -107,6 +127,36 @@ class _ContactRouteDialogState extends State { } } + List 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 = []; + final seen = {}; + 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 _loadHashSizePreference() async { final hashSize = await RouteHashPreferences.getHashSize(); if (!mounted) return; @@ -114,6 +164,7 @@ class _ContactRouteDialogState extends State { _selectedHashSize = hashSize; }); _reparse(); + _syncMapSelectionFromController(); } String _tokenFor(Contact contact, int hashSize) { @@ -125,23 +176,168 @@ class _ContactRouteDialogState extends State { return hex.substring(0, length); } - void _appendHop(Contact contact) { - final token = _tokenFor(contact, _selectedHashSize); - final current = _controller.text.trim(); - _controller.text = current.isEmpty ? token : '$current,$token'; + void _syncControllerFromSelectedHops() { + final tokens = _selectedMapHops + .map((contact) => _tokenFor(contact, _selectedHashSize)) + .toList(); + _controller.text = tokens.join(','); _controller.selection = TextSelection.fromPosition( 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( + _controller.text.codeUnits.fold(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(); + 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 Widget build(BuildContext context) { final appProvider = context.watch(); - final routeCandidates = - widget.availableContacts - .where((contact) => contact.isRepeater || contact.isRoom) - .toList() - ..sort((a, b) => a.displayName.compareTo(b.displayName)); + final routeCandidates = _routeCandidates; + final connectionProvider = context.watch(); + final selfPoint = + connectionProvider.deviceInfo.advLat != null && + 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 = [ + ...?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 = [ + ...?selfPoint == null ? null : [selfPoint], + ...?recipientPoint == null ? null : [recipientPoint], + ...routeCandidates.map( + (contact) => LatLng( + contact.displayLocation!.latitude, + contact.displayLocation!.longitude, + ), + ), + ]; return FractionallySizedBox( heightFactor: 0.85, @@ -155,83 +351,193 @@ class _ContactRouteDialogState extends State { style: Theme.of(context).textTheme.headlineSmall, ), const SizedBox(height: 16), - TextField( - controller: _controller, - textCapitalization: TextCapitalization.characters, - decoration: InputDecoration( - labelText: 'Route', - hintText: _selectedHashSize == 1 - ? 'AA,BB,CC' - : _selectedHashSize == 2 - ? 'AABB,CCDD' - : 'AABBCC,DDEEFF', - helperText: - 'Use comma-separated hops. Path byte size comes from global Settings. Colon form like AA:BB is also accepted.', - errorText: _errorText, - border: const OutlineInputBorder(), - ), - ), - const SizedBox(height: 12), - Text( - _parsedRoute == null - ? 'Preview: enter a route to validate it.' - : 'Preview: ${_parsedRoute!.summary} • ${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}', - style: Theme.of(context).textTheme.bodySmall, - ), - if (_parsedRoute != null) ...[ - const SizedBox(height: 4), - SelectableText( - _parsedRoute!.canonicalText, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'), - ), - ], - const SizedBox(height: 16), - _AutomationRoutingInfo( - isExpanded: _showRoutingInfo, - onToggle: () { - setState(() { - _showRoutingInfo = !_showRoutingInfo; - }); - }, - autoRouteRotationEnabled: appProvider.autoRouteRotationEnabled, - nearestRelayFallbackEnabled: - appProvider.nearestRelayFallbackEnabled, - clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry, - ), - 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)}', + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _controller, + textCapitalization: TextCapitalization.characters, + decoration: InputDecoration( + labelText: 'Route', + hintText: _selectedHashSize == 1 + ? 'AA,BB,CC' + : _selectedHashSize == 2 + ? 'AABB,CCDD' + : 'AABBCC,DDEEFF', + helperText: + 'Use comma-separated hops. Path byte size comes from global Settings. Colon form like AA:BB is also accepted.', + errorText: _errorText, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + Text( + _parsedRoute == null + ? 'Preview: enter a route to validate it.' + : 'Preview: ${_parsedRoute!.summary} • ${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}', + style: Theme.of(context).textTheme.bodySmall, + ), + if (_parsedRoute != null) ...[ + const SizedBox(height: 4), + SelectableText( + _parsedRoute!.canonicalText, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', ), ), - ); - }, + ], + 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( + isExpanded: _showRoutingInfo, + onToggle: () { + setState(() { + _showRoutingInfo = !_showRoutingInfo; + }); + }, + autoRouteRotationEnabled: + appProvider.autoRouteRotationEnabled, + nearestRelayFallbackEnabled: + appProvider.nearestRelayFallbackEnabled, + clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry, + ), + const SizedBox(height: 16), + ], ), ), - const SizedBox(height: 16), - Row( + ), + OverflowBar( + alignment: MainAxisAlignment.spaceBetween, + spacing: 8, + overflowSpacing: 8, children: [ TextButton( onPressed: () => Navigator.of(context).pop(), @@ -244,13 +550,16 @@ class _ContactRouteDialogState extends State { ).pop(const ContactRouteDialogResult.clear()), child: const Text('Clear Route'), ), - const Spacer(), FilledButton( onPressed: _parsedRoute == null ? null - : () => Navigator.of( - context, - ).pop(ContactRouteDialogResult.set(_parsedRoute!)), + : () => Navigator.of(context).pop( + ContactRouteDialogResult.setWithFallback( + _parsedRoute!, + inferredFallbackLocation: + _buildSyntheticFallbackLocation(), + ), + ), child: const Text('Set Route'), ), ], @@ -262,6 +571,31 @@ class _ContactRouteDialogState extends State { } } +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 { final bool isExpanded; final VoidCallback onToggle; diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index bc5ebde..0fde571 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -128,6 +128,24 @@ class ContactTile extends StatelessWidget { final Widget subtitleWidget = Column( crossAxisAlignment: CrossAxisAlignment.start, 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) ...[ const SizedBox(height: 2), _buildLocationLine( @@ -591,6 +609,7 @@ class ContactTile extends StatelessWidget { contact.publicKey, signedEncodedPathLen: parsedRoute.signedEncodedPathLen, paddedPathBytes: parsedRoute.paddedPathBytes, + inferredFallbackLocation: routeResult.inferredFallbackLocation, ); 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( BuildContext context, { required double latitude, diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index 134a168..69feaaa 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -1788,6 +1788,7 @@ class _MessageBubbleState extends State { receptionDetails?.rssiDbm ?? matchedRxLog?.logRxDataInfo?.rssiDbm ?? message.lastEchoRssiDbm; + final routeMetadata = messagesProvider.getMessageRouteMetadata(message.id); // Look up contact information for rich display name final contactsProvider = context.read(); @@ -2515,7 +2516,7 @@ class _MessageBubbleState extends State { switch (recipient.deliveryStatus) { case MessageDeliveryStatus.delivered: statusColor = Colors.green; - statusIcon = Icons.check_circle; + statusIcon = Icons.done_all; statusText = recipient.roundTripTimeMs != null ? '${recipient.roundTripTimeMs}ms' @@ -2523,6 +2524,15 @@ class _MessageBubbleState extends State { context, )!.delivered; break; + case MessageDeliveryStatus.sent: + statusColor = Theme.of( + context, + ).colorScheme.onSurfaceVariant; + statusIcon = Icons.done; + statusText = AppLocalizations.of( + context, + )!.sent; + break; case MessageDeliveryStatus.failed: statusColor = Colors.red; statusIcon = Icons.cancel; @@ -2531,7 +2541,6 @@ class _MessageBubbleState extends State { )!.failed; break; case MessageDeliveryStatus.sending: - case MessageDeliveryStatus.sent: default: statusColor = Colors.orange; statusIcon = Icons.schedule; @@ -2726,6 +2735,7 @@ class _MessageBubbleState extends State { context, message: message, isSarMarker: isSarMarker, + routeMetadata: routeMetadata, ), ], ); diff --git a/lib/widgets/messages/message_bubble_header.dart b/lib/widgets/messages/message_bubble_header.dart index 496d739..bf7d32a 100644 --- a/lib/widgets/messages/message_bubble_header.dart +++ b/lib/widgets/messages/message_bubble_header.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../models/contact.dart'; import '../../models/message.dart'; +import '../../models/message_route_metadata.dart'; import '../../utils/avatar_label_helper.dart'; import '../../utils/message_extensions.dart'; import '../common/contact_avatar.dart'; @@ -59,6 +60,7 @@ Widget buildBubbleMetaFooter( BuildContext context, { required Message message, required bool isSarMarker, + MessageRouteMetadata? routeMetadata, }) { final metaColor = Theme.of( context, @@ -86,12 +88,13 @@ Widget buildBubbleMetaFooter( ).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([ Icon(Icons.alt_route, size: 11, color: metaColor), const SizedBox(width: 3), Text( - message.pathLen == 0 ? 'direct' : '${message.pathLen}hop', + effectivePathLen == 0 ? 'direct' : '${effectivePathLen}hop', style: Theme.of( context, ).textTheme.labelSmall?.copyWith(color: metaColor), @@ -124,6 +127,9 @@ Widget buildBubbleMetaFooter( ); } +int _effectivePathLen(Message message, MessageRouteMetadata? routeMetadata) => + routeMetadata?.hopCount ?? message.pathLen; + Widget buildChannelHeaderPill( BuildContext context, { required String label, diff --git a/lib/widgets/messages/message_bubble_signal.dart b/lib/widgets/messages/message_bubble_signal.dart index 3902db0..168a42c 100644 --- a/lib/widgets/messages/message_bubble_signal.dart +++ b/lib/widgets/messages/message_bubble_signal.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../models/message.dart'; +import '../../models/message_route_metadata.dart'; import '../../models/path_selection.dart'; import '../../models/message_reception_details.dart'; import '../../providers/messages_provider.dart'; @@ -209,7 +210,7 @@ Widget buildSentDirectSignalStatus( _techChip( context, icon: Icons.alt_route, - label: hopDisplayLabel(message), + label: hopDisplayLabelForMessage(message, routeMetadata), color: Colors.indigo, ), _techChip( @@ -294,6 +295,17 @@ String hopDisplayLabel(Message message) { 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( BuildContext context, { required IconData icon, diff --git a/test/providers/contacts_provider_test.dart b/test/providers/contacts_provider_test.dart index cea9df9..b5b94a9 100644 --- a/test/providers/contacts_provider_test.dart +++ b/test/providers/contacts_provider_test.dart @@ -458,6 +458,24 @@ void main() { 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', () { final route = ContactRouteCodec.parse('AA,BB,CC'); provider.setContactRouteLocal( @@ -576,46 +594,49 @@ void main() { expect(distanceMeters, closeTo(100.0, 8.0)); }); - test('does not infer a fallback location when the contact advertises one', () { - final repeaterKey = Uint8List.fromList([ - 0xCC, - 0xDD, - 0x10, - 0x11, - 0x12, - 0x13, - ...List.generate(26, (index) => index + 20), - ]); - provider.addOrUpdateContact( - createContact( - key: repeaterKey, - type: ContactType.repeater, - name: 'Relay Alpha', - ), - ); + test( + 'does not infer a fallback location when the contact advertises one', + () { + final repeaterKey = Uint8List.fromList([ + 0xCC, + 0xDD, + 0x10, + 0x11, + 0x12, + 0x13, + ...List.generate(26, (index) => index + 20), + ]); + provider.addOrUpdateContact( + createContact( + key: repeaterKey, + type: ContactType.repeater, + name: 'Relay Alpha', + ), + ); - final targetKey = createPublicKey(121); - final route = ContactRouteCodec.parse('AABB,CCDD'); - provider.addOrUpdateContact( - Contact( - publicKey: targetKey, - type: ContactType.chat, - flags: 0, - outPathLen: route.signedEncodedPathLen, - outPath: route.paddedPathBytes, - advName: 'Has Advert', - lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, - advLat: (45.1234 * 1e6).toInt(), - advLon: (13.8765 * 1e6).toInt(), - lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, - ), - ); + final targetKey = createPublicKey(121); + final route = ContactRouteCodec.parse('AABB,CCDD'); + provider.addOrUpdateContact( + Contact( + publicKey: targetKey, + type: ContactType.chat, + flags: 0, + outPathLen: route.signedEncodedPathLen, + outPath: route.paddedPathBytes, + advName: 'Has Advert', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: (45.1234 * 1e6).toInt(), + advLon: (13.8765 * 1e6).toInt(), + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), + ); - final updated = provider.findContactByKey(targetKey)!; - expect(updated.displayLocation, isNotNull); - expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.000001)); - expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.000001)); - }); + final updated = provider.findContactByKey(targetKey)!; + expect(updated.displayLocation, isNotNull); + expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.000001)); + expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.000001)); + }, + ); }); group('ContactsProvider.updateFastGps', () { diff --git a/test/services/contact_route_resolver_test.dart b/test/services/contact_route_resolver_test.dart new file mode 100644 index 0000000..8cd5587 --- /dev/null +++ b/test/services/contact_route_resolver_test.dart @@ -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, + ); +}