Files
meshcore-sar_android/lib/screens/live_traffic_screen.dart
2026-04-03 19:46:51 +02:00

1247 lines
39 KiB
Dart

import 'dart:async';
import 'package:crypto/crypto.dart';
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/ble_packet_log.dart';
import '../models/contact.dart';
import '../providers/channels_provider.dart';
import '../providers/contacts_provider.dart';
import '../providers/connection_provider.dart';
import '../services/live_traffic_summary.dart';
import '../services/location_tracking_service.dart';
import '../services/route_hash_preferences.dart';
import '../services/traffic_stats_reporting_service.dart';
import '../utils/log_rx_route_decoder.dart';
import '../widgets/compact_signal_indicator.dart';
import '../widgets/messages/message_trace_sheet.dart';
import 'packet_log_screen.dart';
import '../l10n/app_localizations.dart';
T? _maybeProvider<T>(BuildContext context) {
try {
return Provider.of<T>(context, listen: false);
} catch (_) {
return null;
}
}
class LiveTrafficScreen extends StatefulWidget {
final List<BlePacketLog> Function() logReader;
final Listenable? refreshListenable;
final DateTime Function() now;
final VoidCallback? openPacketLogs;
const LiveTrafficScreen({
super.key,
required this.logReader,
this.refreshListenable,
DateTime Function()? now,
this.openPacketLogs,
}) : now = now ?? DateTime.now;
factory LiveTrafficScreen.fromProvider(
ConnectionProvider provider, {
Key? key,
VoidCallback? openPacketLogs,
}) {
return LiveTrafficScreen(
key: key,
logReader: () => provider.bleService.packetLogs,
refreshListenable: provider,
openPacketLogs: openPacketLogs,
);
}
@override
State<LiveTrafficScreen> createState() => _LiveTrafficScreenState();
}
class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
static const List<Duration> _windowOptions = [
Duration(minutes: 1),
Duration(minutes: 5),
Duration(minutes: 10),
Duration(minutes: 15),
Duration(minutes: 30),
Duration(minutes: 60),
];
Timer? _ticker;
DateTime? _clearedAt;
int _preferredHashSize = RouteHashPreferences.defaultHashSize;
Duration _selectedWindow = const Duration(minutes: 1);
String? _selectedPacketType;
@override
void initState() {
super.initState();
_loadPreferredHashSize();
widget.refreshListenable?.addListener(_handleRefresh);
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) {
setState(() {});
}
});
}
@override
void didUpdateWidget(covariant LiveTrafficScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.refreshListenable != widget.refreshListenable) {
oldWidget.refreshListenable?.removeListener(_handleRefresh);
widget.refreshListenable?.addListener(_handleRefresh);
}
}
@override
void dispose() {
widget.refreshListenable?.removeListener(_handleRefresh);
_ticker?.cancel();
super.dispose();
}
void _handleRefresh() {
if (!mounted) return;
setState(() {});
}
Future<void> _loadPreferredHashSize() async {
final hashSize = await RouteHashPreferences.getHashSize();
if (!mounted) return;
setState(() {
_preferredHashSize = hashSize;
});
}
@override
Widget build(BuildContext context) {
final logs = widget.logReader();
final totalRxDataCount = LiveTrafficSummary.countRxDataLogs(logs);
final unfilteredSnapshot = LiveTrafficSummary.fromLogs(
logs,
now: widget.now(),
clearedAt: _clearedAt,
preferredHashSize: _preferredHashSize,
window: _selectedWindow,
);
final snapshot = LiveTrafficSummary.fromLogs(
logs,
now: widget.now(),
clearedAt: _clearedAt,
preferredHashSize: _preferredHashSize,
window: _selectedWindow,
packetTypeFilter: _selectedPacketType,
);
final theme = Theme.of(context);
final contactsProvider = _maybeProvider<ContactsProvider>(context);
final routeHashCounts = _RouteHashCounts.fromContacts(
contactsProvider?.contacts ?? const <Contact>[],
);
final packetTypes =
unfilteredSnapshot.visibleEntries
.map((entry) => entry.payloadLabel)
.toSet()
.toList()
..sort();
final filteredEntries = snapshot.visibleEntries;
return Scaffold(
appBar: AppBar(
title: Text(AppLocalizations.of(context)!.liveTraffic),
actions: [
if (widget.openPacketLogs != null)
IconButton(
onPressed: widget.openPacketLogs,
tooltip: 'Open packet logs',
icon: const Icon(Icons.list_alt_rounded),
),
IconButton(
onPressed: _openStatsDashboard,
tooltip: 'View public stats',
icon: const Icon(Icons.open_in_new),
),
IconButton(
onPressed: () => _showPacketTypeHelpSheet(context),
tooltip: 'Packet type help',
icon: const Icon(Icons.help_outline),
),
IconButton(
onPressed: () {
setState(() {
_clearedAt = widget.now();
});
},
tooltip: 'Clear live view',
icon: const Icon(Icons.cleaning_services_outlined),
),
],
),
body: CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
sliver: SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SummaryPanel(
snapshot: snapshot,
totalRxCount: totalRxDataCount,
routeHashCounts: routeHashCounts,
onWindowTap: () => _showWindowPicker(context),
),
const SizedBox(height: 10),
_PacketTypeFilterBar(
packetTypes: packetTypes,
selectedType: _selectedPacketType,
onSelected: (value) {
setState(() {
_selectedPacketType = value;
});
},
),
],
),
),
),
if (filteredEntries.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.radar_rounded,
size: 64,
color: theme.colorScheme.primary.withValues(
alpha: 0.45,
),
),
const SizedBox(height: 16),
const Text(
'No packets for this filter',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
unfilteredSnapshot.visibleEntries.isEmpty
? 'This view only shows in-memory traffic while it is active.'
: 'Try a different packet type or switch back to All.',
textAlign: TextAlign.center,
style: TextStyle(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
)
else
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
sliver: SliverList.separated(
itemBuilder: (context, index) {
final entry = filteredEntries[index];
return _LiveTrafficCard(entry: entry, now: widget.now());
},
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemCount: filteredEntries.length,
),
),
],
),
);
}
Future<void> _openStatsDashboard() async {
final url = TrafficStatsReportingService.dashboardUri;
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
}
}
Future<void> _showWindowPicker(BuildContext context) async {
final selected = await showModalBottomSheet<Duration>(
context: context,
showDragHandle: true,
builder: (context) {
return SafeArea(
child: ListView(
shrinkWrap: true,
children: [
for (final option in _windowOptions)
ListTile(
title: Text(_windowLabel(option)),
trailing: option == _selectedWindow
? const Icon(Icons.check)
: null,
onTap: () => Navigator.of(context).pop(option),
),
],
),
);
},
);
if (selected == null || !mounted) return;
setState(() {
_selectedWindow = selected;
});
}
Future<void> _showPacketTypeHelpSheet(BuildContext context) {
final packetTypes = LiveTrafficEntry.knownPayloadTypes;
return showModalBottomSheet<void>(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (context) {
final scheme = Theme.of(context).colorScheme;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: ListView(
shrinkWrap: true,
children: [
Text(
'Packet Types',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 6),
Text(
'Descriptions below follow the current MeshCore payload definitions.',
style: TextStyle(color: scheme.onSurfaceVariant),
),
const SizedBox(height: 16),
for (final packetType in packetTypes) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(14),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
packetType.title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 4),
Text(
packetType.description,
style: TextStyle(
fontSize: 13,
color: scheme.onSurfaceVariant,
),
),
],
),
),
const SizedBox(height: 10),
],
],
),
),
);
},
);
}
}
class _SummaryPanel extends StatelessWidget {
final LiveTrafficSnapshot snapshot;
final int? totalRxCount;
final _RouteHashCounts routeHashCounts;
final VoidCallback onWindowTap;
const _SummaryPanel({
required this.snapshot,
required this.totalRxCount,
required this.routeHashCounts,
required this.onWindowTap,
});
@override
Widget build(BuildContext context) {
final busynessColor = switch (snapshot.busyness) {
LiveTrafficBusyness.quiet => Colors.blueGrey,
LiveTrafficBusyness.active => Colors.orange,
LiveTrafficBusyness.busy => Colors.redAccent,
};
final busynessLabel = switch (snapshot.busyness) {
LiveTrafficBusyness.quiet => 'Quiet',
LiveTrafficBusyness.active => 'Active',
LiveTrafficBusyness.busy => 'Busy',
};
final metrics = [
_MetricTile(
tileKey: const ValueKey('liveTrafficMetric:rxPackets'),
label: AppLocalizations.of(context)!.rxPackets,
value: '${snapshot.rxCount}',
subtitle: totalRxCount == null
? _windowSummaryLabel(snapshot.windowDuration)
: 'Device total $totalRxCount',
),
_MetricTile(
tileKey: const ValueKey('liveTrafficMetric:rssi'),
label: AppLocalizations.of(context)!.rssi,
value: snapshot.latestRssiDbm == null
? 'No RX data'
: '${snapshot.latestRssiDbm} dBm',
subtitle: snapshot.avgRssiDbm == null
? 'No average yet'
: 'Avg ${snapshot.avgRssiDbm!.toStringAsFixed(1)} dBm',
),
_MetricTile(
tileKey: const ValueKey('liveTrafficMetric:snr'),
label: AppLocalizations.of(context)!.snr,
value: snapshot.latestSnrDb == null
? 'No RX data'
: '${snapshot.latestSnrDb!.toStringAsFixed(1)} dB',
subtitle: snapshot.avgSnrDb == null
? 'No average yet'
: 'Avg ${snapshot.avgSnrDb!.toStringAsFixed(1)} dB',
),
_MetricTile(
tileKey: const ValueKey('liveTrafficMetric:multihop'),
label: AppLocalizations.of(context)!.multihop,
value: '${snapshot.multiHopCount}',
subtitle: routeHashCounts.summaryLabel,
footer: snapshot.avgHopCount == null
? 'No routes yet'
: 'Avg ${snapshot.avgHopCount!.toStringAsFixed(1)} hops',
),
];
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
busynessColor.withValues(alpha: 0.18),
Theme.of(context).colorScheme.surfaceContainerHigh,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(20),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 6,
runSpacing: 6,
children: [
_SummaryBadge(
label: AppLocalizations.of(context)!.mesh,
value: busynessLabel,
color: busynessColor,
),
_SummaryBadge(
label: AppLocalizations.of(context)!.rate,
value: '${snapshot.packetsPerMinute} pkt/min',
color: Theme.of(context).colorScheme.primary,
),
_SummaryBadge(
label: AppLocalizations.of(context)!.window,
value: _windowLabel(snapshot.windowDuration),
color: Theme.of(context).colorScheme.secondary,
onTap: onWindowTap,
),
],
),
const SizedBox(height: 14),
LayoutBuilder(
builder: (context, constraints) {
const spacing = 10.0;
const minTileWidth = 150.0;
final columns =
((constraints.maxWidth + spacing) / (minTileWidth + spacing))
.floor()
.clamp(1, metrics.length);
final tileWidth =
(constraints.maxWidth - (spacing * (columns - 1))) / columns;
return Wrap(
spacing: spacing,
runSpacing: spacing,
children: [
for (final metric in metrics)
SizedBox(width: tileWidth, child: metric),
],
);
},
),
],
),
);
}
}
class _SummaryBadge extends StatelessWidget {
final String label;
final String value;
final Color color;
final VoidCallback? onTap;
const _SummaryBadge({
required this.label,
required this.value,
required this.color,
this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(999),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 6),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: 0.35)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$label ',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
Text(
value,
style: const TextStyle(fontWeight: FontWeight.w800, fontSize: 12),
),
],
),
),
);
}
}
class _PacketTypeFilterBar extends StatelessWidget {
final List<String> packetTypes;
final String? selectedType;
final ValueChanged<String?> onSelected;
const _PacketTypeFilterBar({
required this.packetTypes,
required this.selectedType,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_FilterChip(
label: AppLocalizations.of(context)!.all,
selected: selectedType == null,
onTap: () => onSelected(null),
),
for (final type in packetTypes) ...[
const SizedBox(width: 8),
_FilterChip(
label: type,
selected: selectedType == type,
onTap: () => onSelected(type),
),
],
],
),
);
}
}
class _FilterChip extends StatelessWidget {
final String label;
final bool selected;
final VoidCallback onTap;
const _FilterChip({
required this.label,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final color = selected ? scheme.primary : scheme.outline;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(999),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: color.withValues(alpha: selected ? 0.14 : 0.08),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: color.withValues(alpha: selected ? 0.45 : 0.22),
),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: color,
),
),
),
);
}
}
String _windowLabel(Duration duration) {
return '${duration.inMinutes} min';
}
String _windowSummaryLabel(Duration duration) {
final minutes = duration.inMinutes;
if (minutes == 1) return 'Last 1 min';
return 'Last $minutes min';
}
class _MetricTile extends StatelessWidget {
final Key? tileKey;
final String label;
final String value;
final String subtitle;
final String? footer;
const _MetricTile({
this.tileKey,
required this.label,
required this.value,
required this.subtitle,
this.footer,
});
@override
Widget build(BuildContext context) {
return Container(
key: tileKey,
height: 136,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 6),
Text(
value,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800),
),
const SizedBox(height: 4),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
if (footer != null) ...[
const SizedBox(height: 2),
Text(
footer!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
],
),
),
],
),
);
}
}
class _RouteHashCounts {
final int oneByte;
final int twoByte;
final int threeByte;
const _RouteHashCounts({
required this.oneByte,
required this.twoByte,
required this.threeByte,
});
factory _RouteHashCounts.fromContacts(List<Contact> contacts) {
var oneByte = 0;
var twoByte = 0;
var threeByte = 0;
for (final contact in contacts) {
if (!contact.routeHasPath || contact.routeHopCount <= 0) continue;
switch (contact.routeHashSize) {
case 1:
oneByte += 1;
break;
case 2:
twoByte += 1;
break;
case 3:
threeByte += 1;
break;
}
}
return _RouteHashCounts(
oneByte: oneByte,
twoByte: twoByte,
threeByte: threeByte,
);
}
String get summaryLabel => '1b:$oneByte 2b:$twoByte 3b:$threeByte';
}
class _LiveTrafficCard extends StatelessWidget {
final LiveTrafficEntry entry;
final DateTime now;
const _LiveTrafficCard({required this.entry, required this.now});
@override
Widget build(BuildContext context) {
final log = entry.log;
final isRx = log.direction == PacketDirection.rx;
final accent = isRx ? Colors.green : Colors.blue;
final rxInfo = log.logRxDataInfo;
final originDistance = _originDistanceLabel(context, entry);
final channelsProvider = _maybeProvider<ChannelsProvider>(context);
final packetDetails = _LiveTrafficPacketDetails.fromEntry(
entry,
channelsProvider: channelsProvider,
);
final signalMetric = SignalMetric.fromRxInfo(rxInfo);
return Material(
color: Colors.transparent,
child: InkWell(
onTap: () => _showPacketBytesSheet(context, log.rawData),
onLongPress: () => _showTraceSheet(context, entry),
borderRadius: BorderRadius.circular(18),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: accent.withValues(alpha: 0.25)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(999),
),
child: Text(
isRx ? 'RX' : 'TX',
style: TextStyle(
color: accent,
fontWeight: FontWeight.w800,
fontSize: 12,
),
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
packetDetails.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
if (entry.payloadMeaning != null)
Text(
entry.payloadMeaning!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
],
),
),
if (signalMetric != null) ...[
const SizedBox(width: 12),
CompactSignalIndicator(metric: signalMetric),
] else
Text(
_timeAgo(log.timestamp, now),
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 10),
_PacketInfoLine(
text:
'${_formatClock(log.timestamp)} • Size: ${log.rawData.length} bytes',
),
_PacketInfoLine(text: 'Hash: ${packetDetails.packetHashHex}'),
if (packetDetails.pathLine != null)
_PacketInfoLine(text: packetDetails.pathLine!),
if (packetDetails.pathHashLine != null)
_PacketInfoLine(text: packetDetails.pathHashLine!),
if (packetDetails.endpointLine != null)
_PacketInfoLine(text: packetDetails.endpointLine!),
if (originDistance != null)
_PacketInfoLine(text: 'Origin: $originDistance'),
],
),
),
),
);
}
static String _timeAgo(DateTime timestamp, DateTime now) {
final diff = now.difference(timestamp);
if (diff.inSeconds < 60) return '${diff.inSeconds}s ago';
return '${diff.inMinutes}m ago';
}
static String _formatClock(DateTime timestamp) {
final hour = timestamp.hour.toString().padLeft(2, '0');
final minute = timestamp.minute.toString().padLeft(2, '0');
final second = timestamp.second.toString().padLeft(2, '0');
return '$hour:$minute:$second';
}
static String? _originDistanceLabel(
BuildContext context,
LiveTrafficEntry entry,
) {
final route = entry.route;
if (route == null || route.hopHashes.isEmpty) {
return null;
}
final contactsProvider = _maybeProvider<ContactsProvider>(context);
final connectionProvider = _maybeProvider<ConnectionProvider>(context);
final ownLatLng = _ownLatLng(connectionProvider);
final resolved = LogRxRouteDecoder.resolveHash(
route.hopHashes.first,
contacts: contactsProvider?.contacts ?? const <Contact>[],
ownPublicKey: connectionProvider?.deviceInfo.publicKey,
ownName:
connectionProvider?.deviceInfo.selfName ??
connectionProvider?.deviceInfo.displayName,
ownLatitude: ownLatLng?.latitude,
ownLongitude: ownLatLng?.longitude,
);
if (resolved.latitude == null || resolved.longitude == null) {
return null;
}
final currentPosition = LocationTrackingService().currentPosition;
final originDistanceMeters = currentPosition != null
? Geolocator.distanceBetween(
currentPosition.latitude,
currentPosition.longitude,
resolved.latitude!,
resolved.longitude!,
)
: ownLatLng != null
? Geolocator.distanceBetween(
ownLatLng.latitude,
ownLatLng.longitude,
resolved.latitude!,
resolved.longitude!,
)
: null;
if (originDistanceMeters == null) {
return null;
}
return _formatDistance(originDistanceMeters);
}
static _GeoPoint? _ownLatLng(ConnectionProvider? connectionProvider) {
final advLat = connectionProvider?.deviceInfo.advLat;
final advLon = connectionProvider?.deviceInfo.advLon;
if (advLat == null || advLon == null || (advLat == 0 && advLon == 0)) {
return null;
}
return _GeoPoint(advLat / 1e6, advLon / 1e6);
}
static String _formatDistance(double meters) {
if (meters < 1000) {
return '${meters.round()}m';
}
return '${(meters / 1000).toStringAsFixed(1)}km';
}
static Future<void> _showPacketBytesSheet(
BuildContext context,
List<int> bytes,
) {
final data = bytes.toList(growable: false);
final hexLines = <String>[];
const bytesPerLine = 16;
for (var offset = 0; offset < data.length; offset += bytesPerLine) {
final chunk = data.skip(offset).take(bytesPerLine).toList();
final hex = chunk
.map((byte) => byte.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(' ');
hexLines.add(
'${offset.toRadixString(16).padLeft(4, '0').toUpperCase()}: $hex',
);
}
final ascii = data
.map(
(byte) =>
(byte >= 32 && byte <= 126) ? String.fromCharCode(byte) : '.',
)
.join();
return showModalBottomSheet<void>(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (context) {
final scheme = Theme.of(context).colorScheme;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Packet Bytes',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 6),
Text(
'${data.length} bytes',
style: TextStyle(color: scheme.onSurfaceVariant),
),
const SizedBox(height: 16),
Text('Hex', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(14),
),
child: SelectableText(
hexLines.join('\n'),
style: TextStyle(
fontSize: 12,
height: 1.45,
fontFamily: 'monospace',
color: scheme.onSurface,
),
),
),
const SizedBox(height: 16),
Text('ASCII', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(14),
),
child: SelectableText(
ascii.isEmpty ? '(empty)' : ascii,
style: TextStyle(
fontSize: 12,
height: 1.45,
fontFamily: 'monospace',
color: scheme.onSurface,
),
),
),
],
),
),
),
);
},
);
}
static Future<void> _showTraceSheet(
BuildContext context,
LiveTrafficEntry entry,
) {
final route = entry.route;
if (route == null || route.pathBytes.isEmpty) {
return Future.value();
}
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => MessageTraceSheet.packetPath(
packetPath: route.pathBytes,
descriptionOverride:
'Relay path from packet path bytes (${route.hopHashes.length} hop${route.hopHashes.length == 1 ? '' : 's'})',
noRelayMatchTextOverride:
'No named nodes could be matched for this packet path.',
),
);
}
}
class _LiveTrafficPacketDetails {
final String title;
final String packetHashHex;
final String? pathLine;
final String? pathHashLine;
final String? endpointLine;
const _LiveTrafficPacketDetails({
required this.title,
required this.packetHashHex,
required this.pathLine,
required this.pathHashLine,
required this.endpointLine,
});
factory _LiveTrafficPacketDetails.fromEntry(
LiveTrafficEntry entry, {
ChannelsProvider? channelsProvider,
}) {
final route = entry.route;
final payloadType = route?.payloadType;
final parsedPayload = _ParsedTrafficPayload.tryParse(
entry.log.rawData,
route,
channelsProvider: channelsProvider,
);
final title = switch (payloadType) {
0x05 => parsedPayload?.channelDisplayName ?? LiveTrafficEntry.payloadTypeTitle(0x05),
0x06 => parsedPayload?.channelDisplayName ?? LiveTrafficEntry.payloadTypeTitle(0x06),
null => entry.payloadLabel.toUpperCase(),
_ => LiveTrafficEntry.payloadTypeTitle(payloadType),
};
final hopHashes = route?.hopHashes ?? const <String>[];
final pathLine = hopHashes.isEmpty
? null
: 'Path: ${hopHashes.length} hop${hopHashes.length == 1 ? '' : 's'} [${hopHashes.join(',')}]';
final pathHashLine = route == null
? null
: 'Path Hashes: ${route.hashSize}-byte per hop';
return _LiveTrafficPacketDetails(
title: title,
packetHashHex: _packetHash(entry.log.rawData),
pathLine: pathLine,
pathHashLine: pathHashLine,
endpointLine: parsedPayload?.endpointLine,
);
}
static String _packetHash(List<int> bytes) {
final digest = md5.convert(bytes).bytes;
return digest
.take(8)
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase();
}
}
class _ParsedTrafficPayload {
final String? endpointLine;
final String? channelDisplayName;
const _ParsedTrafficPayload({this.endpointLine, this.channelDisplayName});
static _ParsedTrafficPayload? tryParse(
List<int> rawData,
DecodedLogRxRoute? route, {
ChannelsProvider? channelsProvider,
}) {
if (rawData.length < 5 ||
rawData.first != LiveTrafficSummary.logRxDataResponseCode) {
return null;
}
final rawPacketData = rawData.sublist(3);
if (rawPacketData.length < 2) return null;
final header = rawPacketData[0];
final routeType = header & 0x03;
final payloadType = (header >> 2) & 0x0F;
var index = 1;
if (routeType == 0x00 || routeType == 0x03) {
if (rawPacketData.length < index + 5) return null;
index += 4;
}
if (rawPacketData.length <= index) return null;
final pathDescriptor = rawPacketData[index++];
final pathByteLen =
LogRxRouteDecoder.descriptorByteLength(pathDescriptor) ??
(pathDescriptor == 0xFF ? 0 : null);
if (pathByteLen == null || rawPacketData.length < index + pathByteLen) {
return null;
}
index += pathByteLen;
final payload = rawPacketData.sublist(index);
final senderHash = route?.hopHashes.isNotEmpty == true
? route!.hopHashes.first
: null;
switch (payloadType) {
case 0x05:
case 0x06:
if (payload.isEmpty) return const _ParsedTrafficPayload();
final channelHash = payload.first;
final channelHashHex = channelHash
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
final channelDisplayName = channelsProvider
?.getUniqueChannelDisplayNameByHashByte(channelHash);
return _ParsedTrafficPayload(
channelDisplayName: channelDisplayName,
endpointLine: channelDisplayName == null
? 'Channel Hash: $channelHashHex'
: 'Channel: $channelDisplayName ($channelHashHex)',
);
case 0x00:
case 0x01:
case 0x07:
if (payload.isEmpty) return const _ParsedTrafficPayload();
final destinationHash = payload.first
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
return _ParsedTrafficPayload(
endpointLine: senderHash == null
? 'To: <$destinationHash>'
: 'From: <${senderHash.toUpperCase()}> To: <$destinationHash>',
);
default:
return const _ParsedTrafficPayload();
}
}
}
class _PacketInfoLine extends StatelessWidget {
final String text;
const _PacketInfoLine({required this.text});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
text,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurface,
),
),
);
}
}
class _GeoPoint {
final double latitude;
final double longitude;
const _GeoPoint(this.latitude, this.longitude);
}
void openLiveTrafficScreen(BuildContext context, ConnectionProvider provider) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => LiveTrafficScreen.fromProvider(
provider,
openPacketLogs: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PacketLogScreen(bleService: provider.bleService),
),
);
},
),
),
);
}