fix: Use zero-hop relay ping history

This commit is contained in:
Janez T
2026-03-31 18:13:53 +02:00
parent 3392e5f9c1
commit 05ddb6841a
2 changed files with 215 additions and 77 deletions

View File

@@ -2073,25 +2073,6 @@ class ConnectionProvider with ChangeNotifier {
_pendingRelayPings[nonce] = completer; _pendingRelayPings[nonce] = completer;
_relayPingStartTimes[nonce] = DateTime.now().millisecondsSinceEpoch; _relayPingStartTimes[nonce] = DateTime.now().millisecondsSinceEpoch;
// Map ContactType to hop type: chat=0, repeater=1, room=2, sensor=3
int hopType;
switch (contact.type) {
case ContactType.chat:
hopType = 0;
break;
case ContactType.repeater:
hopType = 1;
break;
case ContactType.room:
hopType = 2;
break;
case ContactType.sensor:
hopType = 3;
break;
default:
hopType = 0;
}
// Timeout after 10 seconds // Timeout after 10 seconds
final timer = Timer(const Duration(seconds: 10), () { final timer = Timer(const Duration(seconds: 10), () {
_pendingRelayPings.remove(nonce); _pendingRelayPings.remove(nonce);
@@ -2104,9 +2085,10 @@ class ConnectionProvider with ChangeNotifier {
}); });
try { try {
// Zero-hop ping: prefixSize=1 sends 1 byte of public key, hopType=0
await _activeService.sendTracePath( await _activeService.sendTracePath(
nonce: nonce, nonce: nonce,
hopType: hopType, prefixSize: 1,
contactPublicKey: contact.publicKey, contactPublicKey: contact.publicKey,
); );
final result = await completer.future; final result = await completer.future;

View File

@@ -2359,9 +2359,21 @@ class _PingRelaySheet extends StatefulWidget {
State<_PingRelaySheet> createState() => _PingRelaySheetState(); State<_PingRelaySheet> createState() => _PingRelaySheetState();
} }
class _PingEntry {
final RelayPingResult result;
final DateTime timestamp;
final String? distance;
const _PingEntry({
required this.result,
required this.timestamp,
this.distance,
});
}
class _PingRelaySheetState extends State<_PingRelaySheet> { class _PingRelaySheetState extends State<_PingRelaySheet> {
bool _pinging = false; bool _pinging = false;
final List<RelayPingResult> _history = []; final List<_PingEntry> _history = [];
@override @override
void initState() { void initState() {
@@ -2372,19 +2384,26 @@ class _PingRelaySheetState extends State<_PingRelaySheet> {
Future<void> _doPing() async { Future<void> _doPing() async {
setState(() => _pinging = true); setState(() => _pinging = true);
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final distance = _distanceText();
final result = await connectionProvider.pingRelay(widget.contact); final result = await connectionProvider.pingRelay(widget.contact);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_pinging = false; _pinging = false;
_history.insert(0, result); _history.insert(
0,
_PingEntry(
result: result,
timestamp: DateTime.now(),
distance: distance,
),
);
}); });
} }
String? _distanceText() { String? _distanceText() {
final location = widget.contact.displayLocation; final location = widget.contact.displayLocation;
if (location == null) return null; if (location == null) return null;
final currentPosition = final currentPosition = LocationTrackingService().currentPosition;
LocationTrackingService().currentPosition;
if (currentPosition == null) return null; if (currentPosition == null) return null;
final meters = Geolocator.distanceBetween( final meters = Geolocator.distanceBetween(
currentPosition.latitude, currentPosition.latitude,
@@ -2397,11 +2416,183 @@ class _PingRelaySheetState extends State<_PingRelaySheet> {
return '${(meters / 1000).toStringAsFixed(1)} km'; return '${(meters / 1000).toStringAsFixed(1)} km';
} }
Widget _buildPill(
BuildContext context, {
required IconData icon,
required String label,
Color? iconColor,
}) {
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: iconColor ?? colorScheme.onSurfaceVariant),
const SizedBox(width: 4),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
Widget _buildSnrPill(BuildContext context, String direction, double snrDb) {
final quality = linkQualityLabel(null, snrDb);
final color = linkQualityColor(quality);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
direction == 'there'
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 12,
color: color,
),
const SizedBox(width: 4),
Text(
'${snrDb.toStringAsFixed(1)} dB',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: color,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
Widget _buildResultRow(BuildContext context, _PingEntry entry, int seq) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final r = entry.result;
final age = DateTime.now().difference(entry.timestamp);
final timeAgo = age.toLocalizedTimeAgoWithSeconds(context);
if (!r.success) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
CircleAvatar(
radius: 12,
backgroundColor: colorScheme.error.withValues(alpha: 0.15),
child: Text(
'$seq',
style: TextStyle(
color: colorScheme.error,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 10),
Text(
'Timeout',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.error,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
Text(
timeAgo,
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
),
],
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
radius: 12,
backgroundColor: colorScheme.surfaceContainerHighest,
child: Text(
'$seq',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 10),
_buildPill(
context,
icon: Icons.timer_outlined,
label: '${r.durationMs} ms',
),
const SizedBox(width: 6),
_buildSnrPill(context, 'there', r.snrThere),
const SizedBox(width: 6),
_buildSnrPill(context, 'back', r.snrBack),
],
),
Padding(
padding: const EdgeInsets.only(left: 34, top: 4),
child: Row(
children: [
if (entry.distance != null) ...[
Icon(Icons.straighten, size: 10,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5)),
const SizedBox(width: 3),
Text(
entry.distance!,
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
fontSize: 10,
),
),
const SizedBox(width: 8),
],
Icon(Icons.schedule, size: 10,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5)),
const SizedBox(width: 3),
Text(
timeAgo,
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
fontSize: 10,
),
),
],
),
),
],
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final displayName = widget.contact.displayName; final displayName = widget.contact.displayName;
final distance = _distanceText();
return SafeArea( return SafeArea(
child: Padding( child: Padding(
@@ -2429,30 +2620,32 @@ class _PingRelaySheetState extends State<_PingRelaySheet> {
style: theme.textTheme.titleLarge, style: theme.textTheme.titleLarge,
), ),
), ),
if (_history.isNotEmpty)
IconButton(
onPressed: () => setState(() => _history.clear()),
icon: const Icon(Icons.delete_outline, size: 20),
tooltip: 'Clear history',
style: IconButton.styleFrom(
foregroundColor: colorScheme.onSurfaceVariant,
),
),
FilledButton.icon( FilledButton.icon(
onPressed: _pinging ? null : _doPing, onPressed: _pinging ? null : _doPing,
icon: _pinging icon: _pinging
? const SizedBox( ? SizedBox(
width: 16, width: 16,
height: 16, height: 16,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
color: Colors.white, color: colorScheme.onPrimary,
), ),
) )
: const Icon(Icons.refresh, size: 18), : const Icon(Icons.network_ping, size: 18),
label: Text(_pinging ? 'Pinging...' : 'Ping Again'), label: Text(_pinging ? 'Pinging...' : 'Ping Again'),
), ),
], ],
), ),
if (distance != null) ...[ const SizedBox(height: 16),
const SizedBox(height: 4),
Text(
'Distance: $distance',
style: theme.textTheme.bodySmall,
),
],
const SizedBox(height: 12),
if (_history.isEmpty && _pinging) if (_history.isEmpty && _pinging)
const Padding( const Padding(
padding: EdgeInsets.symmetric(vertical: 24), padding: EdgeInsets.symmetric(vertical: 24),
@@ -2474,49 +2667,12 @@ class _PingRelaySheetState extends State<_PingRelaySheet> {
child: ListView.separated( child: ListView.separated(
shrinkWrap: true, shrinkWrap: true,
itemCount: _history.length, itemCount: _history.length,
separatorBuilder: (_, _) => const Divider(height: 1), separatorBuilder: (_, _) =>
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.3)),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final r = _history[index]; final entry = _history[index];
final seq = _history.length - index; final seq = _history.length - index;
if (!r.success) { return _buildResultRow(context, entry, seq);
return ListTile(
dense: true,
leading: CircleAvatar(
radius: 14,
backgroundColor: theme.colorScheme.error,
child: Text(
'$seq',
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
title: const Text('Timeout'),
subtitle: const Text('No response received'),
);
}
return ListTile(
dense: true,
leading: CircleAvatar(
radius: 14,
backgroundColor: Colors.green,
child: Text(
'$seq',
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
title: Text('${r.durationMs} ms'),
subtitle: Text(
'SNR there: ${r.snrThere.toStringAsFixed(1)} dB '
'SNR back: ${r.snrBack.toStringAsFixed(1)} dB',
),
);
}, },
), ),
), ),