Warn channels about delayed message

This commit is contained in:
Janez T
2026-03-13 20:14:07 +01:00
parent d4237b7569
commit 9f31f50e61
6 changed files with 231 additions and 26 deletions

View File

@@ -3,24 +3,42 @@ import 'package:latlong2/latlong.dart';
import '../services/mesh_map_nodes_service.dart'; import '../services/mesh_map_nodes_service.dart';
class ResolvedTraceNode { class ResolvedTraceNode {
final MeshMapNode? node; final List<MeshMapNode> candidates;
final int matchCount; final int matchCount;
final bool usedOnlineFallback; final bool usedOnlineFallback;
final int selectedIndex;
const ResolvedTraceNode({ const ResolvedTraceNode({
required this.node, required this.candidates,
required this.matchCount, required this.matchCount,
required this.usedOnlineFallback, required this.usedOnlineFallback,
this.selectedIndex = 0,
}); });
MeshMapNode? get node =>
candidates.isEmpty ? null : candidates[selectedIndex];
bool get hasMatch => node != null; bool get hasMatch => node != null;
bool get isAmbiguous => matchCount > 1; bool get isAmbiguous => matchCount > 1;
bool get canCycle => candidates.length > 1;
String? get matchSummary { String? get matchSummary {
if (matchCount <= 1) return null; if (matchCount <= 1) return null;
final source = usedOnlineFallback ? 'online' : 'local'; final source = usedOnlineFallback ? 'online' : 'local';
return '$matchCount $source matches'; 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 { class TraceNodeResolver {
@@ -38,7 +56,7 @@ class TraceNodeResolver {
}) { }) {
if (prefixHex == null || prefixHex.isEmpty) { if (prefixHex == null || prefixHex.isEmpty) {
return const ResolvedTraceNode( return const ResolvedTraceNode(
node: null, candidates: <MeshMapNode>[],
matchCount: 0, matchCount: 0,
usedOnlineFallback: false, usedOnlineFallback: false,
); );
@@ -49,7 +67,7 @@ class TraceNodeResolver {
.toList(); .toList();
if (allMatches.isEmpty) { if (allMatches.isEmpty) {
return const ResolvedTraceNode( return const ResolvedTraceNode(
node: null, candidates: <MeshMapNode>[],
matchCount: 0, matchCount: 0,
usedOnlineFallback: false, usedOnlineFallback: false,
); );
@@ -84,7 +102,7 @@ class TraceNodeResolver {
}); });
return ResolvedTraceNode( return ResolvedTraceNode(
node: pool.first, candidates: List<MeshMapNode>.unmodifiable(pool),
matchCount: pool.length, matchCount: pool.length,
usedOnlineFallback: usedOnlineFallback, usedOnlineFallback: usedOnlineFallback,
); );

View File

@@ -23,6 +23,7 @@ class ContactTraceSheet extends StatefulWidget {
class _ContactTraceSheetState extends State<ContactTraceSheet> { class _ContactTraceSheetState extends State<ContactTraceSheet> {
late final Future<_ContactTraceResult> _future; late final Future<_ContactTraceResult> _future;
_ContactTraceResult? _traceOverride;
@override @override
void initState() { void initState() {
@@ -89,7 +90,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
); );
} }
final trace = snapshot.data!; final trace = _traceOverride ?? snapshot.data!;
final routeEntries = _displayRouteEntries(trace); final routeEntries = _displayRouteEntries(trace);
final concreteNodes = routeEntries final concreteNodes = routeEntries
.where((entry) => entry.resolved.node != null) .where((entry) => entry.resolved.node != null)
@@ -99,7 +100,9 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
.map((node) => LatLng(node.latitude, node.longitude)) .map((node) => LatLng(node.latitude, node.longitude))
.toList(); .toList();
final hasMapPath = mapPoints.length >= 2; final hasMapPath = mapPoints.length >= 2;
final relayNodes = trace.matchedRelayNodes.whereType<MeshMapNode>(); final relayNodes = trace.matchedRelayNodes
.map((entry) => entry.node)
.whereType<MeshMapNode>();
return SizedBox( return SizedBox(
height: MediaQuery.of(context).size.height * 0.75, height: MediaQuery.of(context).size.height * 0.75,
@@ -249,6 +252,15 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
), ),
...routeEntries.asMap().entries.map( ...routeEntries.asMap().entries.map(
(entry) => ListTile( (entry) => ListTile(
onTap: entry.value.resolved.canCycle
? () => setState(() {
final baseTrace =
_traceOverride ?? snapshot.data!;
_traceOverride = baseTrace.cycleEntry(
entry.value.target,
);
})
: null,
leading: CircleAvatar( leading: CircleAvatar(
radius: 14, radius: 14,
backgroundColor: entry.key == 0 backgroundColor: entry.key == 0
@@ -267,8 +279,11 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
), ),
title: Text(entry.value.label), title: Text(entry.value.label),
subtitle: Text( 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), const SizedBox(height: 12),
@@ -313,7 +328,12 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
List<_RouteDisplayEntry> _displayRouteEntries(_ContactTraceResult trace) { List<_RouteDisplayEntry> _displayRouteEntries(_ContactTraceResult trace) {
final entries = <_RouteDisplayEntry>[]; final entries = <_RouteDisplayEntry>[];
if (trace.sender.node != null) { if (trace.sender.node != null) {
entries.add(_RouteDisplayEntry.fromResolved(trace.sender)); entries.add(
_RouteDisplayEntry.fromResolved(
trace.sender,
target: const _RouteEntryTarget.sender(),
),
);
} }
entries.addAll( entries.addAll(
trace.matchedRelayNodes.asMap().entries.map((entry) { trace.matchedRelayNodes.asMap().entries.map((entry) {
@@ -325,11 +345,17 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
label: node?.name ?? 'Unknown', label: node?.name ?? 'Unknown',
keyLabel: node != null ? _prefixKeyLabel(node.publicKey) : hashHex, keyLabel: node != null ? _prefixKeyLabel(node.publicKey) : hashHex,
matchSummary: resolved.matchSummary, matchSummary: resolved.matchSummary,
target: _RouteEntryTarget.relayNode(entry.key),
); );
}), }),
); );
if (trace.recipient.node != null) { if (trace.recipient.node != null) {
entries.add(_RouteDisplayEntry.fromResolved(trace.recipient)); entries.add(
_RouteDisplayEntry.fromResolved(
trace.recipient,
target: const _RouteEntryTarget.recipient(),
),
);
} }
return entries; return entries;
} }
@@ -493,6 +519,34 @@ class _ContactTraceResult {
required this.routeHashes, required this.routeHashes,
required this.matchedRelayNodes, 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 { class _RouteDisplayEntry {
@@ -500,17 +554,22 @@ class _RouteDisplayEntry {
final String label; final String label;
final String? keyLabel; final String? keyLabel;
final String? matchSummary; final String? matchSummary;
final _RouteEntryTarget target;
const _RouteDisplayEntry({ const _RouteDisplayEntry({
required this.resolved, required this.resolved,
required this.label, required this.label,
required this.keyLabel, required this.keyLabel,
required this.matchSummary, required this.matchSummary,
required this.target,
}); });
MeshMapNode? get node => resolved.node; MeshMapNode? get node => resolved.node;
factory _RouteDisplayEntry.fromResolved(ResolvedTraceNode resolved) { factory _RouteDisplayEntry.fromResolved(
ResolvedTraceNode resolved, {
required _RouteEntryTarget target,
}) {
final node = resolved.node!; final node = resolved.node!;
return _RouteDisplayEntry( return _RouteDisplayEntry(
resolved: resolved, resolved: resolved,
@@ -520,6 +579,21 @@ class _RouteDisplayEntry {
math.min(12, node.publicKey.length), math.min(12, node.publicKey.length),
), ),
matchSummary: resolved.matchSummary, 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);
}

View File

@@ -27,6 +27,7 @@ class MessageTraceSheet extends StatefulWidget {
class _MessageTraceSheetState extends State<MessageTraceSheet> { class _MessageTraceSheetState extends State<MessageTraceSheet> {
late final Future<_TraceResult> _future; late final Future<_TraceResult> _future;
_TraceResult? _traceOverride;
@override @override
void initState() { void initState() {
@@ -53,7 +54,6 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
final recipientPrefix = widget.message.recipientPublicKey != null final recipientPrefix = widget.message.recipientPublicKey != null
? _toPrefixHex(widget.message.recipientPublicKey) ? _toPrefixHex(widget.message.recipientPublicKey)
: _toPrefixHex(connectionProvider.deviceInfo.publicKey); : _toPrefixHex(connectionProvider.deviceInfo.publicKey);
final localNodes = _localNodesFromContacts(contactsProvider); final localNodes = _localNodesFromContacts(contactsProvider);
final localPublicKeys = localNodes.map((node) => node.publicKey).toSet(); final localPublicKeys = localNodes.map((node) => node.publicKey).toSet();
var trace = _buildTraceResult( var trace = _buildTraceResult(
@@ -115,7 +115,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
); );
} }
final trace = snapshot.data!; final trace = _traceOverride ?? snapshot.data!;
final routeEntries = _displayRouteEntries(trace); final routeEntries = _displayRouteEntries(trace);
final concretePathNodes = routeEntries final concretePathNodes = routeEntries
.where((entry) => entry.resolved.node != null) .where((entry) => entry.resolved.node != null)
@@ -275,6 +275,15 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
), ),
...routeEntries.asMap().entries.map( ...routeEntries.asMap().entries.map(
(entry) => ListTile( (entry) => ListTile(
onTap: entry.value.resolved.canCycle
? () => setState(() {
final baseTrace =
_traceOverride ?? snapshot.data!;
_traceOverride = baseTrace.cycleEntry(
entry.value.target,
);
})
: null,
leading: CircleAvatar( leading: CircleAvatar(
radius: 14, radius: 14,
backgroundColor: entry.key == 0 backgroundColor: entry.key == 0
@@ -293,8 +302,11 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
), ),
title: Text(entry.value.label), title: Text(entry.value.label),
subtitle: Text( 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), const SizedBox(height: 12),
@@ -360,10 +372,16 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
if (pathNodes.isEmpty) { if (pathNodes.isEmpty) {
return [ return [
if (trace.sender.node != null) if (trace.sender.node != null)
_RouteDisplayEntry.fromResolved(trace.sender), _RouteDisplayEntry.fromResolved(
trace.sender,
target: const _RouteEntryTarget.sender(),
),
if (trace.recipient.node != null && if (trace.recipient.node != null &&
trace.recipient.node!.publicKey != trace.sender.node?.publicKey) 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<MessageTraceSheet> {
? _prefixKeyLabel(entry.value.node!.publicKey) ? _prefixKeyLabel(entry.value.node!.publicKey)
: hashHex, : hashHex,
matchSummary: entry.value.matchSummary, matchSummary: entry.value.matchSummary,
target: _RouteEntryTarget.pathNode(entry.key),
); );
}).toList(); }).toList();
final lastKey = pathNodes.last.publicKey; final lastKey = pathNodes.last.publicKey;
@@ -384,7 +403,10 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
...entries, ...entries,
if (trace.recipient.node != null && if (trace.recipient.node != null &&
trace.recipient.node!.publicKey != lastKey) 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<MessageTraceSheet> {
final lastKey = pathNodes.last.publicKey; final lastKey = pathNodes.last.publicKey;
return [ return [
if (trace.sender.node != null && trace.sender.node!.publicKey != firstKey) if (trace.sender.node != null && trace.sender.node!.publicKey != firstKey)
_RouteDisplayEntry.fromResolved(trace.sender), _RouteDisplayEntry.fromResolved(
trace.sender,
target: const _RouteEntryTarget.sender(),
),
...trace.matchedPathNodes ...trace.matchedPathNodes
.where((entry) => entry.node != null) .asMap()
.map(_RouteDisplayEntry.fromResolved), .entries
.where((entry) => entry.value.node != null)
.map(
(entry) => _RouteDisplayEntry.fromResolved(
entry.value,
target: _RouteEntryTarget.pathNode(entry.key),
),
),
if (trace.recipient.node != null && if (trace.recipient.node != null &&
trace.recipient.node!.publicKey != lastKey) 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<MessageTraceSheet> {
if (senderNode.node != null) senderNode, if (senderNode.node != null) senderNode,
...inferred.map( ...inferred.map(
(node) => ResolvedTraceNode( (node) => ResolvedTraceNode(
node: node, candidates: [node],
matchCount: 1, matchCount: 1,
usedOnlineFallback: false, usedOnlineFallback: false,
), ),
@@ -688,6 +723,37 @@ class _TraceResult {
required this.pathHashes, required this.pathHashes,
required this.matchedPathNodes, 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 { class _RouteDisplayEntry {
@@ -695,17 +761,22 @@ class _RouteDisplayEntry {
final String label; final String label;
final String? keyLabel; final String? keyLabel;
final String? matchSummary; final String? matchSummary;
final _RouteEntryTarget target;
const _RouteDisplayEntry({ const _RouteDisplayEntry({
required this.resolved, required this.resolved,
required this.label, required this.label,
required this.keyLabel, required this.keyLabel,
required this.matchSummary, required this.matchSummary,
required this.target,
}); });
MeshMapNode? get node => resolved.node; MeshMapNode? get node => resolved.node;
factory _RouteDisplayEntry.fromResolved(ResolvedTraceNode resolved) { factory _RouteDisplayEntry.fromResolved(
ResolvedTraceNode resolved, {
required _RouteEntryTarget target,
}) {
final node = resolved.node!; final node = resolved.node!;
return _RouteDisplayEntry( return _RouteDisplayEntry(
resolved: resolved, resolved: resolved,
@@ -715,6 +786,21 @@ class _RouteDisplayEntry {
math.min(12, node.publicKey.length), math.min(12, node.publicKey.length),
), ),
matchSummary: resolved.matchSummary, 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);
}

View File

@@ -795,8 +795,8 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "."
ref: "0efc820" ref: "461f010"
resolved-ref: "0efc8200a80a6bf8eb2fe305f212cb3c0c0965c4" resolved-ref: "461f010a2f1fcd343357c152228891e295d391be"
url: "https://github.com/dz0ny/meshcore_client.git" url: "https://github.com/dz0ny/meshcore_client.git"
source: git source: git
version: "0.1.0" version: "0.1.0"

View File

@@ -44,7 +44,7 @@ dependencies:
meshcore_client: meshcore_client:
git: git:
url: https://github.com/dz0ny/meshcore_client.git url: https://github.com/dz0ny/meshcore_client.git
ref: "b66e268" ref: "461f010"
# Codec2 ultra-low-bitrate speech codec (FFI plugin) # Codec2 ultra-low-bitrate speech codec (FFI plugin)
codec2_flutter: codec2_flutter:

View File

@@ -61,6 +61,33 @@ void main() {
expect(resolved.usedOnlineFallback, isTrue); expect(resolved.usedOnlineFallback, isTrue);
expect(resolved.matchCount, 1); 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({ MeshMapNode _node({