diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 56cb1cc..d191370 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1308,5 +1308,24 @@ "listFilter_repeaters": "Repeaters", "listFilter_roomServers": "Room servers", "listFilter_unreadOnly": "Unread only", - "listFilter_newGroup": "New group" + "listFilter_newGroup": "New group", + + "pathTrace_you": "You", + "pathTrace_failed": "Path trace failed.", + "pathTrace_notAvailable": "Path trace not available.", + "pathTrace_refreshTooltip": "Refresh Path Trace.", + "contacts_pathTrace": "Path Trace", + "contacts_ping": "Ping", + "contacts_repeaterPathTrace": "Path trace to repeater", + "contacts_repeaterPing": "Ping repeater", + "contacts_roomPathTrace": "Path trace to room server", + "contacts_roomPing": "Ping room server", + "contacts_chatTraceRoute": "Path trace route", + "contacts_pathTraceTo": "Trace route to {name}", + "@contacts_pathTraceTo": { + "placeholders": { + "name": {"type": "String"} + } + } + } diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 01e777a..d3fcaa0 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -54,14 +54,11 @@ class _ContactsScreenState extends State final ContactGroupStore _groupStore = ContactGroupStore(); List _groups = []; Timer? _searchDebounce; - StreamSubscription? _frameSubscription; - Uint8List _tagData = Uint8List(4); @override void initState() { super.initState(); _loadGroups(); - _setupFrameListener(); } @override @@ -71,44 +68,6 @@ class _ContactsScreenState extends State super.dispose(); } - void _setupFrameListener() { - final connector = Provider.of(context, listen: false); - - // Listen for incoming text messages from the repeater - _frameSubscription = connector.receivedFrames.listen((frame) { - if (frame.isEmpty) return; - - if (frame[0] == respCodeSent) { - _tagData = frame.sublist(2, 6); - print("Stored tag data: $_tagData"); - } - - // Check if it's a binary response - if (frame[0] == pushCodeTraceData && listEquals(frame.sublist(4, 8), _tagData)) { - if (!mounted) return; - _handleTraceResponse(frame); - } - }); - } - - Future _handleTraceResponse(Uint8List frame)async { - final buffer = BufferReader(frame); - buffer.skipBytes(2); // Skip push code and reserved byte - int pathLength = buffer.readUInt8(); - buffer.skipBytes(5); // Skip Flag byte and tag data - buffer.skipBytes(4); // Skip auth code - Uint8List pathData = buffer.readBytes(pathLength); - Uint8List snrData = buffer.readRemainingBytes(); - print("Received path data length: $pathLength, SNR data length: ${snrData.length}"); - showDialog( - context: context, - builder: (context) => PathTraceDialog( - pathData: pathData, - snrData: snrData, - ), - ); - } - Future _loadGroups() async { final groups = await _groupStore.loadGroups(); if (!mounted) return; @@ -799,16 +758,14 @@ class _ContactsScreenState extends State if (isRepeater) ...[ ListTile( leading: const Icon(Icons.radar, color: Colors.green), - title: Text("Ping"), - onTap: () async { - Navigator.pop(sheetContext); - final frame = buildTraceReq( - DateTime.now().millisecondsSinceEpoch ~/ 1000, - 0, - 0, - payload: Uint8List.fromList([0x85,0x91,0x07,0x91,0x85]) //contact.publicKey.sublist(0,1), - ); - await connector.sendFrame(frame); + title: contact.pathLength > 0 ? Text(context.l10n.contacts_pathTrace) : Text(context.l10n.contacts_ping), + onTap: () { + showDialog(context: context, builder: (context) { + return PathTraceDialog( + title: contact.pathLength > 0 ? context.l10n.contacts_repeaterPathTrace : context.l10n.contacts_repeaterPing, + path: contact.traceRouteBytes ?? Uint8List(0), + ); + }); } ), ListTile( @@ -822,15 +779,14 @@ class _ContactsScreenState extends State ]else if (isRoom) ...[ ListTile( leading: const Icon(Icons.radar, color: Colors.green), - title: Text("Ping"), - onTap: () async { - final frame = buildTraceReq( - DateTime.now().millisecondsSinceEpoch ~/ 1000, - 0, - 0, - payload: contact.publicKey.sublist(0,1), - ); - await connector.sendFrame(frame); + title: contact.pathLength > 0 ? Text(context.l10n.contacts_pathTrace) : Text(context.l10n.contacts_ping), + onTap: () { + showDialog(context: context, builder: (context) { + return PathTraceDialog( + title: contact.pathLength > 0 ? context.l10n.contacts_roomPathTrace : context.l10n.contacts_roomPing, + path: contact.traceRouteBytes ?? Uint8List(0), + ); + }); } ), ListTile( @@ -849,7 +805,20 @@ class _ContactsScreenState extends State _showRoomLogin(context, contact, RoomLoginDestination.management); }, ), - ] else + ] else ...[ + if(contact.pathLength > 0) + ListTile( + leading: const Icon(Icons.radar, color: Colors.green), + title: Text(context.l10n.contacts_chatTraceRoute), + onTap: () { + showDialog(context: context, builder: (context) { + return PathTraceDialog( + title: context.l10n.contacts_pathTraceTo(contact.name), + path: contact.traceRouteBytes ?? Uint8List(0), + ); + }); + } + ), ListTile( leading: const Icon(Icons.chat), title: Text(context.l10n.contacts_openChat), @@ -869,6 +838,7 @@ class _ContactsScreenState extends State _confirmDelete(context, connector, contact); }, ), + ], ], ), ), @@ -923,8 +893,6 @@ class _ContactTile extends StatelessWidget { @override Widget build(BuildContext context) { - final shotPublicKey = - "<${contact.publicKeyHex.substring(0, 8)}...${contact.publicKeyHex.substring(contact.publicKeyHex.length - 8)}>"; return ListTile( leading: CircleAvatar( backgroundColor: _getTypeColor(contact.type), @@ -932,7 +900,7 @@ class _ContactTile extends StatelessWidget { ), title: Text(contact.name), subtitle: Text( - '${contact.typeLabel} • ${contact.pathLabel} $shotPublicKey', + '${contact.typeLabel} • ${contact.pathLabel} ${contact.shortPubKeyHex}', ), trailing: Column( mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/screens/repeater_hub_screen.dart b/lib/screens/repeater_hub_screen.dart index 5a545f3..846d0c5 100644 --- a/lib/screens/repeater_hub_screen.dart +++ b/lib/screens/repeater_hub_screen.dart @@ -73,7 +73,7 @@ class RepeaterHubScreen extends StatelessWidget { ), const SizedBox(height: 8), Text( - '<${repeater.publicKeyHex.substring(0, 8)}...${repeater.publicKeyHex.substring(repeater.publicKeyHex.length - 8)}>', + '$repeater.shortPubKeyHex', style: TextStyle(fontSize: 14, color: Colors.grey[600]), ), const SizedBox(height: 8), diff --git a/lib/widgets/path_trace_dialog.dart b/lib/widgets/path_trace_dialog.dart index c5e4cad..2ffb110 100644 --- a/lib/widgets/path_trace_dialog.dart +++ b/lib/widgets/path_trace_dialog.dart @@ -1,50 +1,221 @@ +import 'dart:async'; import 'dart:typed_data'; -import 'package:flutter/material.dart'; -import 'package:meshcore_open/widgets/snr_indicator.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../connector/meshcore_connector.dart'; +import '../connector/meshcore_protocol.dart'; +import '../models/contact.dart'; +import '../widgets/snr_indicator.dart'; +import '../l10n/l10n.dart'; class PathTraceDialog extends StatefulWidget { const PathTraceDialog({ super.key, - required this.pathData, - required this.snrData, + required this.title, + required this.path, }); - final Uint8List pathData; - final Uint8List snrData; + final String title; + final Uint8List path; @override State createState() => _PathTraceDialogState(); } class _PathTraceDialogState extends State { + StreamSubscription? _frameSubscription; + Timer? _timeoutTimer; + + bool _isLoading = false; + bool _failed2Loaded = false; + bool _hasData = false; + Uint8List _pathData = Uint8List(0); + Uint8List _snrData = Uint8List(0) ; + Map _pathContacts = {}; + + @override + void initState() { + super.initState(); + _setupFrameListener(); + _doPathTrace(); + } + + @override + void dispose() { + _frameSubscription?.cancel(); + _timeoutTimer?.cancel(); + super.dispose(); + } + + Future _doPathTrace() async { + if(mounted) { + setState(() { + _isLoading = true; + _failed2Loaded = false; + }); + } + + final connector = Provider.of(context, listen: false); + final frame = buildTraceReq( + DateTime.now().millisecondsSinceEpoch ~/ 1000, + 0, //flags + 0, //auth + payload: widget.path, + ); + connector.sendFrame(frame); + } + + void _setupFrameListener() { + final connector = Provider.of(context, listen: false); + Uint8List tagData = Uint8List(4); + // Listen for incoming text messages from the repeater + _frameSubscription = connector.receivedFrames.listen((frame) { + if (frame.isEmpty) return; + final frameBuffer = BufferReader(frame); + final code = frameBuffer.readUInt8(); + + if (code == respCodeSent) { + frameBuffer.skipBytes(1); //reserved + tagData = frameBuffer.readBytes(4); + final timeoutSeconds = frameBuffer.readUInt32LE(); + + // Start timeout timer for trace response + _timeoutTimer?.cancel(); + _timeoutTimer = Timer(Duration(milliseconds: timeoutSeconds), () { + if (!mounted) return; + setState(() { + _isLoading = false; + _failed2Loaded = true; + }); + }); + } + + // Check if it's a binary response + if (code == pushCodeTraceData && listEquals(frame.sublist(4, 8), tagData)) { + _timeoutTimer?.cancel(); + if (!mounted) return; + frameBuffer.skipBytes(3); //reserved + path length + flag + if(listEquals(frameBuffer.readBytes(4), tagData)){ + _handleTraceResponse(frame); + } + } + }); + } + + Future _handleTraceResponse(Uint8List frame)async { + final connector = Provider.of(context, listen: false); + + final buffer = BufferReader(frame); + buffer.skipBytes(2); // Skip push code and reserved byte + int pathLength = buffer.readUInt8(); + buffer.skipBytes(5); // Skip Flag byte and tag data + buffer.skipBytes(4); // Skip auth code + Uint8List pathData = buffer.readBytes(pathLength); + Uint8List snrData = buffer.readRemainingBytes(); + + Map pathContacts = {}; + + connector.contacts.where((c) => c.type != advTypeChat).forEach(( + repeater, + ) { + for (var neighbourData in pathData) { + if (listEquals( + repeater.publicKey.sublist(0, 1), + Uint8List.fromList([neighbourData]), + )) { + pathContacts[neighbourData] = repeater; + } + } + }); + + setState(() { + _isLoading = false; + _hasData = true; + _pathData = pathData; + _snrData = snrData; + _pathContacts = pathContacts; + }); + } + + String formatDirectionText(int index) { + if (index == 0 || index == _snrData.length - 1) { + if (index == 0) { + return context.l10n.pathTrace_you; + } else { + return _pathContacts[_pathData[_pathData.length - 1]]?.name ?? "0x${_pathData[_pathData.length - 1].toRadixString(16).toUpperCase()}"; + } + } else { + return _pathContacts[_pathData[index-1]]?.name ?? "0x${_pathData[index-1].toRadixString(16).toUpperCase()}"; + } + } + String formatDirectionSubText(int index) { + if (index == 0 || index == _snrData.length - 1) { + if (index == 0) { + return _pathContacts[_pathData[0]]?.name ?? "0x${_pathData[0].toRadixString(16).toUpperCase()}"; + } else { + return context.l10n.pathTrace_you; + } + } else { + return _pathContacts[_pathData[index]]?.name ?? "0x${_pathData[index].toRadixString(16).toUpperCase()}"; + } + } @override Widget build(BuildContext context) { + final l10n = context.l10n; return AlertDialog( - title: const Text('Path Trace'), - content: SizedBox( - width: double.maxFinite, - child: ListView.builder( - itemCount: widget.snrData.length, - itemBuilder: (context, index) { - return ListTile( - leading: index >= widget.snrData.length / 2 ? Icon(Icons.arrow_circle_left) : Icon(Icons.arrow_circle_right), - title: index == 0 || index == widget.snrData.length - 1 ? ( index == 0 ? Text('You to 0x${widget.pathData[0].toRadixString(16).toUpperCase()}') : Text('0x${widget.pathData[widget.pathData.length - 1].toRadixString(16).toUpperCase()} to You')) : Text('0x${widget.pathData[index-1].toRadixString(16).toUpperCase()} to 0x${widget.pathData[index].toRadixString(16).toUpperCase()}'), - trailing: SNRIcon(snr: widget.snrData[index] / 4.0), - onTap: () { - // Handle item tap - }, - - ); - }, + title: Column( children: [ + FittedBox(fit: BoxFit.scaleDown, child: Text(widget.title, style: const TextStyle(fontSize: 24))), + if(_failed2Loaded) + Text(l10n.pathTrace_failed, style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.error),), + ], + ), + content: SafeArea( + child: RefreshIndicator( + onRefresh: _doPathTrace, + child: !_hasData + ? Center( + child: Text(l10n.pathTrace_notAvailable), + ) + : ListView.builder( + itemCount: _snrData.length, + itemBuilder: (context, index) { + return ListTile( + leading: index >= _snrData.length / 2 ? Icon(Icons.call_received) : Icon(Icons.call_made), + title: Text( + formatDirectionText(index), style: const TextStyle(fontSize: 14), + ), + subtitle: Text( + formatDirectionSubText(index), + style: const TextStyle(fontSize: 14), + ), + trailing: SNRIcon(snr: _snrData[index].toSigned(8) / 4.0), + onTap: () { + // Handle item tap + }, + ); + }, + ), ), ), actions: [ + IconButton( + icon: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh), + onPressed: _isLoading ? null : _doPathTrace, + tooltip: l10n.pathTrace_refreshTooltip, + ), TextButton( onPressed: () => Navigator.of(context).pop(), - child: const Text('Close'), + child: Text(l10n.common_close), ), ], );