Fix retry flow and contact filters

This commit is contained in:
Janez T
2026-03-12 14:39:44 +01:00
parent 2d1e6c0ca4
commit abb73a4c65
13 changed files with 1145 additions and 153 deletions

View File

@@ -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({

View File

@@ -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) {

View File

@@ -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();
} }

View File

@@ -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,

View File

@@ -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,
), ),
), ),

View 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);
}
}

View File

@@ -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,83 +351,193 @@ 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),
TextField( Expanded(
controller: _controller, child: SingleChildScrollView(
textCapitalization: TextCapitalization.characters, child: Column(
decoration: InputDecoration( crossAxisAlignment: CrossAxisAlignment.start,
labelText: 'Route', children: [
hintText: _selectedHashSize == 1 TextField(
? 'AA,BB,CC' controller: _controller,
: _selectedHashSize == 2 textCapitalization: TextCapitalization.characters,
? 'AABB,CCDD' decoration: InputDecoration(
: 'AABBCC,DDEEFF', labelText: 'Route',
helperText: hintText: _selectedHashSize == 1
'Use comma-separated hops. Path byte size comes from global Settings. Colon form like AA:BB is also accepted.', ? 'AA,BB,CC'
errorText: _errorText, : _selectedHashSize == 2
border: const OutlineInputBorder(), ? 'AABB,CCDD'
), : 'AABBCC,DDEEFF',
), helperText:
const SizedBox(height: 12), 'Use comma-separated hops. Path byte size comes from global Settings. Colon form like AA:BB is also accepted.',
Text( errorText: _errorText,
_parsedRoute == null border: const OutlineInputBorder(),
? '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, const SizedBox(height: 12),
), Text(
if (_parsedRoute != null) ...[ _parsedRoute == null
const SizedBox(height: 4), ? 'Preview: enter a route to validate it.'
SelectableText( : 'Preview: ${_parsedRoute!.summary}${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
_parsedRoute!.canonicalText, style: Theme.of(context).textTheme.bodySmall,
style: Theme.of( ),
context, if (_parsedRoute != null) ...[
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'), const SizedBox(height: 4),
), SelectableText(
], _parsedRoute!.canonicalText,
const SizedBox(height: 16), style: Theme.of(context).textTheme.bodyMedium?.copyWith(
_AutomationRoutingInfo( fontFamily: 'monospace',
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)}',
), ),
), ),
); ],
}, 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: [ 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;

View File

@@ -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,

View File

@@ -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,
), ),
], ],
); );

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,46 +594,49 @@ 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(
final repeaterKey = Uint8List.fromList([ 'does not infer a fallback location when the contact advertises one',
0xCC, () {
0xDD, final repeaterKey = Uint8List.fromList([
0x10, 0xCC,
0x11, 0xDD,
0x12, 0x10,
0x13, 0x11,
...List<int>.generate(26, (index) => index + 20), 0x12,
]); 0x13,
provider.addOrUpdateContact( ...List<int>.generate(26, (index) => index + 20),
createContact( ]);
key: repeaterKey, provider.addOrUpdateContact(
type: ContactType.repeater, createContact(
name: 'Relay Alpha', key: repeaterKey,
), type: ContactType.repeater,
); name: 'Relay Alpha',
),
);
final targetKey = createPublicKey(121); final targetKey = createPublicKey(121);
final route = ContactRouteCodec.parse('AABB,CCDD'); final route = ContactRouteCodec.parse('AABB,CCDD');
provider.addOrUpdateContact( provider.addOrUpdateContact(
Contact( Contact(
publicKey: targetKey, publicKey: targetKey,
type: ContactType.chat, type: ContactType.chat,
flags: 0, flags: 0,
outPathLen: route.signedEncodedPathLen, outPathLen: route.signedEncodedPathLen,
outPath: route.paddedPathBytes, outPath: route.paddedPathBytes,
advName: 'Has Advert', advName: 'Has Advert',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: (45.1234 * 1e6).toInt(), advLat: (45.1234 * 1e6).toInt(),
advLon: (13.8765 * 1e6).toInt(), advLon: (13.8765 * 1e6).toInt(),
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
), ),
); );
final updated = provider.findContactByKey(targetKey)!; final updated = provider.findContactByKey(targetKey)!;
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', () {

View 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,
);
}