diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index acc0526..6ffe0a4 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -489,7 +489,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
- CURRENT_PROJECT_VERSION = 113;
+ CURRENT_PROJECT_VERSION = 115;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -511,7 +511,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 113;
+ CURRENT_PROJECT_VERSION = 115;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -530,7 +530,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 113;
+ CURRENT_PROJECT_VERSION = 115;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -547,7 +547,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 113;
+ CURRENT_PROJECT_VERSION = 115;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -679,7 +679,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
- CURRENT_PROJECT_VERSION = 113;
+ CURRENT_PROJECT_VERSION = 115;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -702,7 +702,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
- CURRENT_PROJECT_VERSION = 113;
+ CURRENT_PROJECT_VERSION = 115;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 3d869bf..96d2207 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -43,7 +43,7 @@
CFBundleSignature
????
CFBundleVersion
- 113
+ 115
LSRequiresIPhoneOS
ITSAppUsesNonExemptEncryption
diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml
index 5563930..13832ca 100644
--- a/ios/fastlane/report.xml
+++ b/ios/fastlane/report.xml
@@ -5,22 +5,24 @@
-
+
-
+
-
+
-
+
+
+
diff --git a/lib/utils/trace_node_resolver.dart b/lib/utils/trace_node_resolver.dart
index a29808c..be9d22a 100644
--- a/lib/utils/trace_node_resolver.dart
+++ b/lib/utils/trace_node_resolver.dart
@@ -108,6 +108,87 @@ class TraceNodeResolver {
);
}
+ static List alignPathSelections({
+ required List nodes,
+ MeshMapNode? startNode,
+ MeshMapNode? endNode,
+ }) {
+ if (nodes.isEmpty || nodes.any((node) => node.candidates.isEmpty)) {
+ return nodes;
+ }
+
+ final candidateCosts = List.generate(
+ nodes.length,
+ (_) => [],
+ growable: false,
+ );
+ final previousChoice = List.generate(
+ nodes.length,
+ (_) => [],
+ growable: false,
+ );
+
+ for (var i = 0; i < nodes.length; i++) {
+ final currentCandidates = nodes[i].candidates;
+ candidateCosts[i] = List.filled(
+ currentCandidates.length,
+ double.infinity,
+ );
+ previousChoice[i] = List.filled(currentCandidates.length, -1);
+
+ for (var j = 0; j < currentCandidates.length; j++) {
+ final current = currentCandidates[j];
+ if (i == 0) {
+ candidateCosts[i][j] = startNode == null
+ ? 0
+ : _distanceBetweenNodes(startNode, current);
+ continue;
+ }
+
+ final previousCandidates = nodes[i - 1].candidates;
+ for (var k = 0; k < previousCandidates.length; k++) {
+ final candidateCost =
+ candidateCosts[i - 1][k] +
+ _distanceBetweenNodes(previousCandidates[k], current);
+ if (candidateCost < candidateCosts[i][j]) {
+ candidateCosts[i][j] = candidateCost;
+ previousChoice[i][j] = k;
+ }
+ }
+ }
+ }
+
+ var bestLastIndex = 0;
+ var bestLastCost = double.infinity;
+ final lastCandidates = nodes.last.candidates;
+ for (var i = 0; i < lastCandidates.length; i++) {
+ final endCost = endNode == null
+ ? 0
+ : _distanceBetweenNodes(lastCandidates[i], endNode);
+ final totalCost = candidateCosts.last[i] + endCost;
+ if (totalCost < bestLastCost) {
+ bestLastCost = totalCost;
+ bestLastIndex = i;
+ }
+ }
+
+ final selectedIndices = List.filled(nodes.length, 0);
+ selectedIndices[nodes.length - 1] = bestLastIndex;
+ for (var i = nodes.length - 1; i > 0; i--) {
+ selectedIndices[i - 1] = previousChoice[i][selectedIndices[i]];
+ }
+
+ return List.generate(nodes.length, (index) {
+ final resolved = nodes[index];
+ return ResolvedTraceNode(
+ candidates: resolved.candidates,
+ matchCount: resolved.matchCount,
+ usedOnlineFallback: resolved.usedOnlineFallback,
+ selectedIndex: selectedIndices[index],
+ );
+ }, growable: false);
+ }
+
static double _scoreNode(
MeshMapNode node, {
LatLng? referenceA,
@@ -126,6 +207,14 @@ class TraceNodeResolver {
return double.maxFinite;
}
+ static double _distanceBetweenNodes(MeshMapNode a, MeshMapNode b) {
+ return _distance.as(
+ LengthUnit.Meter,
+ LatLng(a.latitude, a.longitude),
+ LatLng(b.latitude, b.longitude),
+ );
+ }
+
static double _distanceToSegmentMeters(LatLng p, LatLng a, LatLng b) {
final ax = a.longitude;
final ay = a.latitude;
diff --git a/lib/widgets/contacts/contact_trace_sheet.dart b/lib/widgets/contacts/contact_trace_sheet.dart
index 9205f87..9d9883c 100644
--- a/lib/widgets/contacts/contact_trace_sheet.dart
+++ b/lib/widgets/contacts/contact_trace_sheet.dart
@@ -420,12 +420,17 @@ class _ContactTraceSheetState extends State {
),
)
.toList();
+ final alignedRelayNodes = TraceNodeResolver.alignPathSelections(
+ nodes: matchedRelayNodes,
+ startNode: senderNode.node,
+ endNode: recipientNode.node,
+ );
return _ContactTraceResult(
sender: senderNode,
recipient: recipientNode,
routeHashes: routeHashes,
- matchedRelayNodes: matchedRelayNodes,
+ matchedRelayNodes: alignedRelayNodes,
);
}
diff --git a/lib/widgets/messages/message_trace_sheet.dart b/lib/widgets/messages/message_trace_sheet.dart
index f528cb3..8b90577 100644
--- a/lib/widgets/messages/message_trace_sheet.dart
+++ b/lib/widgets/messages/message_trace_sheet.dart
@@ -531,12 +531,17 @@ class _MessageTraceSheetState extends State {
senderLatLng: senderLatLng,
recipientLatLng: recipientLatLng,
);
+ final alignedMatched = TraceNodeResolver.alignPathSelections(
+ nodes: matched,
+ startNode: senderNode.node,
+ endNode: recipientNode.node,
+ );
return _TraceResult(
mode: TraceMode.packetPath,
sender: senderNode,
recipient: recipientNode,
pathHashes: hopHashes,
- matchedPathNodes: matched,
+ matchedPathNodes: alignedMatched,
);
}
diff --git a/pubspec.yaml b/pubspec.yaml
index 2cadca7..9f50894 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
-version: 2026.0313.1+28
+version: 2026.0313.2+29
environment:
sdk: ^3.9.2
diff --git a/test/utils/trace_node_resolver_test.dart b/test/utils/trace_node_resolver_test.dart
index be322f2..ec58feb 100644
--- a/test/utils/trace_node_resolver_test.dart
+++ b/test/utils/trace_node_resolver_test.dart
@@ -88,6 +88,65 @@ void main() {
expect(resolved.cycle().node?.name, 'First Match');
expect(resolved.cycle().cycle().node?.name, 'Second Match');
});
+
+ test('aligns ambiguous hops to the closest continuous path', () {
+ final start = _node(
+ name: 'Start',
+ publicKey: 'start00',
+ latitude: 46.000,
+ longitude: 14.000,
+ );
+ final end = _node(
+ name: 'End',
+ publicKey: 'end000',
+ latitude: 46.300,
+ longitude: 14.300,
+ );
+ final hop1Near = _node(
+ name: 'Hop 1 Near',
+ publicKey: 'aa1100',
+ latitude: 46.100,
+ longitude: 14.100,
+ );
+ final hop1Far = _node(
+ name: 'Hop 1 Far',
+ publicKey: 'aa11ff',
+ latitude: 46.250,
+ longitude: 14.000,
+ );
+ final hop2Near = _node(
+ name: 'Hop 2 Near',
+ publicKey: 'bb2200',
+ latitude: 46.200,
+ longitude: 14.200,
+ );
+ final hop2Far = _node(
+ name: 'Hop 2 Far',
+ publicKey: 'bb22ff',
+ latitude: 46.050,
+ longitude: 14.280,
+ );
+
+ final aligned = TraceNodeResolver.alignPathSelections(
+ nodes: [
+ TraceNodeResolver.resolveBest(
+ nodes: [hop1Far, hop1Near],
+ localPublicKeys: {hop1Near.publicKey, hop1Far.publicKey},
+ prefixHex: 'aa11',
+ ),
+ TraceNodeResolver.resolveBest(
+ nodes: [hop2Far, hop2Near],
+ localPublicKeys: {hop2Near.publicKey, hop2Far.publicKey},
+ prefixHex: 'bb22',
+ ),
+ ],
+ startNode: start,
+ endNode: end,
+ );
+
+ expect(aligned[0].node?.name, 'Hop 1 Near');
+ expect(aligned[1].node?.name, 'Hop 2 Near');
+ });
}
MeshMapNode _node({