From 9f31f50e6195ecb2d7e51a3c8e8b08555d9ea5de Mon Sep 17 00:00:00 2001 From: Janez T Date: Fri, 13 Mar 2026 20:14:07 +0100 Subject: [PATCH] Warn channels about delayed message --- lib/utils/trace_node_resolver.dart | 28 ++++- lib/widgets/contacts/contact_trace_sheet.dart | 86 +++++++++++++- lib/widgets/messages/message_trace_sheet.dart | 110 ++++++++++++++++-- pubspec.lock | 4 +- pubspec.yaml | 2 +- test/utils/trace_node_resolver_test.dart | 27 +++++ 6 files changed, 231 insertions(+), 26 deletions(-) diff --git a/lib/utils/trace_node_resolver.dart b/lib/utils/trace_node_resolver.dart index 6a89a1d..a29808c 100644 --- a/lib/utils/trace_node_resolver.dart +++ b/lib/utils/trace_node_resolver.dart @@ -3,24 +3,42 @@ import 'package:latlong2/latlong.dart'; import '../services/mesh_map_nodes_service.dart'; class ResolvedTraceNode { - final MeshMapNode? node; + final List candidates; final int matchCount; final bool usedOnlineFallback; + final int selectedIndex; const ResolvedTraceNode({ - required this.node, + required this.candidates, required this.matchCount, required this.usedOnlineFallback, + this.selectedIndex = 0, }); + MeshMapNode? get node => + candidates.isEmpty ? null : candidates[selectedIndex]; bool get hasMatch => node != null; bool get isAmbiguous => matchCount > 1; + bool get canCycle => candidates.length > 1; String? get matchSummary { if (matchCount <= 1) return null; final source = usedOnlineFallback ? 'online' : 'local'; return '$matchCount $source matches'; } + + String? get cycleSummary => + canCycle ? 'tap to cycle ${selectedIndex + 1}/$matchCount' : null; + + ResolvedTraceNode cycle() { + if (!canCycle) return this; + return ResolvedTraceNode( + candidates: candidates, + matchCount: matchCount, + usedOnlineFallback: usedOnlineFallback, + selectedIndex: (selectedIndex + 1) % candidates.length, + ); + } } class TraceNodeResolver { @@ -38,7 +56,7 @@ class TraceNodeResolver { }) { if (prefixHex == null || prefixHex.isEmpty) { return const ResolvedTraceNode( - node: null, + candidates: [], matchCount: 0, usedOnlineFallback: false, ); @@ -49,7 +67,7 @@ class TraceNodeResolver { .toList(); if (allMatches.isEmpty) { return const ResolvedTraceNode( - node: null, + candidates: [], matchCount: 0, usedOnlineFallback: false, ); @@ -84,7 +102,7 @@ class TraceNodeResolver { }); return ResolvedTraceNode( - node: pool.first, + candidates: List.unmodifiable(pool), matchCount: pool.length, usedOnlineFallback: usedOnlineFallback, ); diff --git a/lib/widgets/contacts/contact_trace_sheet.dart b/lib/widgets/contacts/contact_trace_sheet.dart index e815288..9205f87 100644 --- a/lib/widgets/contacts/contact_trace_sheet.dart +++ b/lib/widgets/contacts/contact_trace_sheet.dart @@ -23,6 +23,7 @@ class ContactTraceSheet extends StatefulWidget { class _ContactTraceSheetState extends State { late final Future<_ContactTraceResult> _future; + _ContactTraceResult? _traceOverride; @override void initState() { @@ -89,7 +90,7 @@ class _ContactTraceSheetState extends State { ); } - final trace = snapshot.data!; + final trace = _traceOverride ?? snapshot.data!; final routeEntries = _displayRouteEntries(trace); final concreteNodes = routeEntries .where((entry) => entry.resolved.node != null) @@ -99,7 +100,9 @@ class _ContactTraceSheetState extends State { .map((node) => LatLng(node.latitude, node.longitude)) .toList(); final hasMapPath = mapPoints.length >= 2; - final relayNodes = trace.matchedRelayNodes.whereType(); + final relayNodes = trace.matchedRelayNodes + .map((entry) => entry.node) + .whereType(); return SizedBox( height: MediaQuery.of(context).size.height * 0.75, @@ -249,6 +252,15 @@ class _ContactTraceSheetState extends State { ), ...routeEntries.asMap().entries.map( (entry) => ListTile( + onTap: entry.value.resolved.canCycle + ? () => setState(() { + final baseTrace = + _traceOverride ?? snapshot.data!; + _traceOverride = baseTrace.cycleEntry( + entry.value.target, + ); + }) + : null, leading: CircleAvatar( radius: 14, backgroundColor: entry.key == 0 @@ -267,8 +279,11 @@ class _ContactTraceSheetState extends State { ), title: Text(entry.value.label), subtitle: Text( - '${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : ' • ${entry.value.matchSummary}'}', + '${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : ' • ${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : ' • ${entry.value.resolved.cycleSummary}'}', ), + trailing: entry.value.resolved.canCycle + ? const Icon(Icons.sync_alt) + : null, ), ), const SizedBox(height: 12), @@ -313,7 +328,12 @@ class _ContactTraceSheetState extends State { List<_RouteDisplayEntry> _displayRouteEntries(_ContactTraceResult trace) { final entries = <_RouteDisplayEntry>[]; if (trace.sender.node != null) { - entries.add(_RouteDisplayEntry.fromResolved(trace.sender)); + entries.add( + _RouteDisplayEntry.fromResolved( + trace.sender, + target: const _RouteEntryTarget.sender(), + ), + ); } entries.addAll( trace.matchedRelayNodes.asMap().entries.map((entry) { @@ -325,11 +345,17 @@ class _ContactTraceSheetState extends State { label: node?.name ?? 'Unknown', keyLabel: node != null ? _prefixKeyLabel(node.publicKey) : hashHex, matchSummary: resolved.matchSummary, + target: _RouteEntryTarget.relayNode(entry.key), ); }), ); if (trace.recipient.node != null) { - entries.add(_RouteDisplayEntry.fromResolved(trace.recipient)); + entries.add( + _RouteDisplayEntry.fromResolved( + trace.recipient, + target: const _RouteEntryTarget.recipient(), + ), + ); } return entries; } @@ -493,6 +519,34 @@ class _ContactTraceResult { required this.routeHashes, required this.matchedRelayNodes, }); + + _ContactTraceResult cycleEntry(_RouteEntryTarget target) { + switch (target.kind) { + case _RouteEntryKind.sender: + return _ContactTraceResult( + sender: sender.cycle(), + recipient: recipient, + routeHashes: routeHashes, + matchedRelayNodes: matchedRelayNodes, + ); + case _RouteEntryKind.recipient: + return _ContactTraceResult( + sender: sender, + recipient: recipient.cycle(), + routeHashes: routeHashes, + matchedRelayNodes: matchedRelayNodes, + ); + case _RouteEntryKind.relayNode: + final updated = matchedRelayNodes.toList(); + updated[target.index] = updated[target.index].cycle(); + return _ContactTraceResult( + sender: sender, + recipient: recipient, + routeHashes: routeHashes, + matchedRelayNodes: updated, + ); + } + } } class _RouteDisplayEntry { @@ -500,17 +554,22 @@ class _RouteDisplayEntry { final String label; final String? keyLabel; final String? matchSummary; + final _RouteEntryTarget target; const _RouteDisplayEntry({ required this.resolved, required this.label, required this.keyLabel, required this.matchSummary, + required this.target, }); MeshMapNode? get node => resolved.node; - factory _RouteDisplayEntry.fromResolved(ResolvedTraceNode resolved) { + factory _RouteDisplayEntry.fromResolved( + ResolvedTraceNode resolved, { + required _RouteEntryTarget target, + }) { final node = resolved.node!; return _RouteDisplayEntry( resolved: resolved, @@ -520,6 +579,21 @@ class _RouteDisplayEntry { math.min(12, node.publicKey.length), ), matchSummary: resolved.matchSummary, + target: target, ); } } + +enum _RouteEntryKind { sender, recipient, relayNode } + +class _RouteEntryTarget { + final _RouteEntryKind kind; + final int index; + + const _RouteEntryTarget._(this.kind, [this.index = 0]); + + const _RouteEntryTarget.sender() : this._(_RouteEntryKind.sender); + const _RouteEntryTarget.recipient() : this._(_RouteEntryKind.recipient); + const _RouteEntryTarget.relayNode(int index) + : this._(_RouteEntryKind.relayNode, index); +} diff --git a/lib/widgets/messages/message_trace_sheet.dart b/lib/widgets/messages/message_trace_sheet.dart index 8e7b4c3..f528cb3 100644 --- a/lib/widgets/messages/message_trace_sheet.dart +++ b/lib/widgets/messages/message_trace_sheet.dart @@ -27,6 +27,7 @@ class MessageTraceSheet extends StatefulWidget { class _MessageTraceSheetState extends State { late final Future<_TraceResult> _future; + _TraceResult? _traceOverride; @override void initState() { @@ -53,7 +54,6 @@ class _MessageTraceSheetState extends State { final recipientPrefix = widget.message.recipientPublicKey != null ? _toPrefixHex(widget.message.recipientPublicKey) : _toPrefixHex(connectionProvider.deviceInfo.publicKey); - final localNodes = _localNodesFromContacts(contactsProvider); final localPublicKeys = localNodes.map((node) => node.publicKey).toSet(); var trace = _buildTraceResult( @@ -115,7 +115,7 @@ class _MessageTraceSheetState extends State { ); } - final trace = snapshot.data!; + final trace = _traceOverride ?? snapshot.data!; final routeEntries = _displayRouteEntries(trace); final concretePathNodes = routeEntries .where((entry) => entry.resolved.node != null) @@ -275,6 +275,15 @@ class _MessageTraceSheetState extends State { ), ...routeEntries.asMap().entries.map( (entry) => ListTile( + onTap: entry.value.resolved.canCycle + ? () => setState(() { + final baseTrace = + _traceOverride ?? snapshot.data!; + _traceOverride = baseTrace.cycleEntry( + entry.value.target, + ); + }) + : null, leading: CircleAvatar( radius: 14, backgroundColor: entry.key == 0 @@ -293,8 +302,11 @@ class _MessageTraceSheetState extends State { ), title: Text(entry.value.label), subtitle: Text( - '${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : ' • ${entry.value.matchSummary}'}', + '${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : ' • ${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : ' • ${entry.value.resolved.cycleSummary}'}', ), + trailing: entry.value.resolved.canCycle + ? const Icon(Icons.sync_alt) + : null, ), ), const SizedBox(height: 12), @@ -360,10 +372,16 @@ class _MessageTraceSheetState extends State { if (pathNodes.isEmpty) { return [ if (trace.sender.node != null) - _RouteDisplayEntry.fromResolved(trace.sender), + _RouteDisplayEntry.fromResolved( + trace.sender, + target: const _RouteEntryTarget.sender(), + ), if (trace.recipient.node != null && trace.recipient.node!.publicKey != trace.sender.node?.publicKey) - _RouteDisplayEntry.fromResolved(trace.recipient), + _RouteDisplayEntry.fromResolved( + trace.recipient, + target: const _RouteEntryTarget.recipient(), + ), ]; } @@ -377,6 +395,7 @@ class _MessageTraceSheetState extends State { ? _prefixKeyLabel(entry.value.node!.publicKey) : hashHex, matchSummary: entry.value.matchSummary, + target: _RouteEntryTarget.pathNode(entry.key), ); }).toList(); final lastKey = pathNodes.last.publicKey; @@ -384,7 +403,10 @@ class _MessageTraceSheetState extends State { ...entries, if (trace.recipient.node != null && trace.recipient.node!.publicKey != lastKey) - _RouteDisplayEntry.fromResolved(trace.recipient), + _RouteDisplayEntry.fromResolved( + trace.recipient, + target: const _RouteEntryTarget.recipient(), + ), ]; } @@ -392,13 +414,26 @@ class _MessageTraceSheetState extends State { final lastKey = pathNodes.last.publicKey; return [ if (trace.sender.node != null && trace.sender.node!.publicKey != firstKey) - _RouteDisplayEntry.fromResolved(trace.sender), + _RouteDisplayEntry.fromResolved( + trace.sender, + target: const _RouteEntryTarget.sender(), + ), ...trace.matchedPathNodes - .where((entry) => entry.node != null) - .map(_RouteDisplayEntry.fromResolved), + .asMap() + .entries + .where((entry) => entry.value.node != null) + .map( + (entry) => _RouteDisplayEntry.fromResolved( + entry.value, + target: _RouteEntryTarget.pathNode(entry.key), + ), + ), if (trace.recipient.node != null && trace.recipient.node!.publicKey != lastKey) - _RouteDisplayEntry.fromResolved(trace.recipient), + _RouteDisplayEntry.fromResolved( + trace.recipient, + target: const _RouteEntryTarget.recipient(), + ), ]; } @@ -515,7 +550,7 @@ class _MessageTraceSheetState extends State { if (senderNode.node != null) senderNode, ...inferred.map( (node) => ResolvedTraceNode( - node: node, + candidates: [node], matchCount: 1, usedOnlineFallback: false, ), @@ -688,6 +723,37 @@ class _TraceResult { required this.pathHashes, required this.matchedPathNodes, }); + + _TraceResult cycleEntry(_RouteEntryTarget target) { + switch (target.kind) { + case _RouteEntryKind.sender: + return _TraceResult( + mode: mode, + sender: sender.cycle(), + recipient: recipient, + pathHashes: pathHashes, + matchedPathNodes: matchedPathNodes, + ); + case _RouteEntryKind.recipient: + return _TraceResult( + mode: mode, + sender: sender, + recipient: recipient.cycle(), + pathHashes: pathHashes, + matchedPathNodes: matchedPathNodes, + ); + case _RouteEntryKind.pathNode: + final updated = matchedPathNodes.toList(); + updated[target.index] = updated[target.index].cycle(); + return _TraceResult( + mode: mode, + sender: sender, + recipient: recipient, + pathHashes: pathHashes, + matchedPathNodes: updated, + ); + } + } } class _RouteDisplayEntry { @@ -695,17 +761,22 @@ class _RouteDisplayEntry { final String label; final String? keyLabel; final String? matchSummary; + final _RouteEntryTarget target; const _RouteDisplayEntry({ required this.resolved, required this.label, required this.keyLabel, required this.matchSummary, + required this.target, }); MeshMapNode? get node => resolved.node; - factory _RouteDisplayEntry.fromResolved(ResolvedTraceNode resolved) { + factory _RouteDisplayEntry.fromResolved( + ResolvedTraceNode resolved, { + required _RouteEntryTarget target, + }) { final node = resolved.node!; return _RouteDisplayEntry( resolved: resolved, @@ -715,6 +786,21 @@ class _RouteDisplayEntry { math.min(12, node.publicKey.length), ), matchSummary: resolved.matchSummary, + target: target, ); } } + +enum _RouteEntryKind { sender, recipient, pathNode } + +class _RouteEntryTarget { + final _RouteEntryKind kind; + final int index; + + const _RouteEntryTarget._(this.kind, [this.index = 0]); + + const _RouteEntryTarget.sender() : this._(_RouteEntryKind.sender); + const _RouteEntryTarget.recipient() : this._(_RouteEntryKind.recipient); + const _RouteEntryTarget.pathNode(int index) + : this._(_RouteEntryKind.pathNode, index); +} diff --git a/pubspec.lock b/pubspec.lock index 104c342..a2575c5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -795,8 +795,8 @@ packages: dependency: "direct main" description: path: "." - ref: "0efc820" - resolved-ref: "0efc8200a80a6bf8eb2fe305f212cb3c0c0965c4" + ref: "461f010" + resolved-ref: "461f010a2f1fcd343357c152228891e295d391be" url: "https://github.com/dz0ny/meshcore_client.git" source: git version: "0.1.0" diff --git a/pubspec.yaml b/pubspec.yaml index 1f391a9..2cadca7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,7 +44,7 @@ dependencies: meshcore_client: git: url: https://github.com/dz0ny/meshcore_client.git - ref: "b66e268" + ref: "461f010" # Codec2 ultra-low-bitrate speech codec (FFI plugin) codec2_flutter: diff --git a/test/utils/trace_node_resolver_test.dart b/test/utils/trace_node_resolver_test.dart index cec0e21..be322f2 100644 --- a/test/utils/trace_node_resolver_test.dart +++ b/test/utils/trace_node_resolver_test.dart @@ -61,6 +61,33 @@ void main() { expect(resolved.usedOnlineFallback, isTrue); expect(resolved.matchCount, 1); }); + + test('cycles through ambiguous local prefix matches', () { + final first = _node( + name: 'First Match', + publicKey: 'cc1100', + latitude: 46.08, + longitude: 14.52, + ); + final second = _node( + name: 'Second Match', + publicKey: 'cc11ff', + latitude: 46.09, + longitude: 14.53, + ); + + final resolved = TraceNodeResolver.resolveBest( + nodes: [second, first], + localPublicKeys: {first.publicKey, second.publicKey}, + prefixHex: 'cc11', + ); + + expect(resolved.matchCount, 2); + expect(resolved.canCycle, isTrue); + expect(resolved.node?.name, 'Second Match'); + expect(resolved.cycle().node?.name, 'First Match'); + expect(resolved.cycle().cycle().node?.name, 'Second Match'); + }); } MeshMapNode _node({