Add advanced path management, debug logging, and fix channel sync

New features:
- In-app debug log viewer with copy/clear functionality
- Advanced path management UI with history and custom path builder
- Battery indicator widget with voltage/percentage toggle
- Contact/channel filtering and sorting improvements
- Repeater command ACK tracking with path history integration

Fixes:
- Switch channel sync from parallel to sequential to prevent timeouts
- Preserve path overrides when contacts refresh from device
- Fix ACK hash computation for SMAZ-encoded messages
- Proper cleanup of pending operations on disconnect
This commit is contained in:
zach
2026-01-02 14:22:39 -07:00
parent 361dfb7808
commit ad911a1d80
32 changed files with 2914 additions and 849 deletions
+89
View File
@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import '../connector/meshcore_connector.dart';
class BatteryUi {
final IconData icon;
final Color? color;
const BatteryUi(this.icon, this.color);
}
BatteryUi batteryUiForPercent(int? percent) {
if (percent == null) {
return const BatteryUi(Icons.battery_unknown, Colors.grey);
}
final p = percent.clamp(0, 100);
return switch (p) {
<= 5 => const BatteryUi(Icons.battery_alert, Colors.redAccent),
<= 15 => const BatteryUi(Icons.battery_0_bar, Colors.redAccent),
<= 30 => const BatteryUi(Icons.battery_1_bar, Colors.orange),
<= 45 => const BatteryUi(Icons.battery_2_bar, Colors.amber),
<= 60 => const BatteryUi(Icons.battery_3_bar, Colors.lightGreen),
<= 80 => const BatteryUi(Icons.battery_5_bar, Colors.green),
_ => const BatteryUi(Icons.battery_full, Colors.green),
};
}
class BatteryIndicator extends StatefulWidget {
final MeshCoreConnector connector;
const BatteryIndicator({
super.key,
required this.connector,
});
@override
State<BatteryIndicator> createState() => _BatteryIndicatorState();
}
class _BatteryIndicatorState extends State<BatteryIndicator> {
bool _showBatteryVoltage = false;
@override
Widget build(BuildContext context) {
final percent = widget.connector.batteryPercent;
final millivolts = widget.connector.batteryMillivolts;
if (millivolts == null) {
return const SizedBox.shrink();
}
final String displayText;
if (_showBatteryVoltage) {
displayText = '${(millivolts / 1000.0).toStringAsFixed(2)}V';
} else {
displayText = percent != null ? '$percent%' : '';
}
final batteryUi = batteryUiForPercent(percent);
return InkWell(
onTap: () {
setState(() {
_showBatteryVoltage = !_showBatteryVoltage;
});
},
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(batteryUi.icon, size: 20, color: batteryUi.color),
const SizedBox(width: 4),
Text(
displayText,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: batteryUi.color,
),
),
],
),
),
);
}
}
+224
View File
@@ -0,0 +1,224 @@
import 'package:flutter/material.dart';
enum ContactSortOption {
lastSeen,
recentMessages,
name,
}
enum ContactTypeFilter {
all,
users,
repeaters,
rooms,
}
class SortFilterMenuOption {
final int value;
final String label;
final bool? checked;
const SortFilterMenuOption({
required this.value,
required this.label,
this.checked,
});
}
class SortFilterMenuSection {
final String title;
final List<SortFilterMenuOption> options;
const SortFilterMenuSection({
required this.title,
required this.options,
});
}
class SortFilterMenu extends StatelessWidget {
final List<SortFilterMenuSection> sections;
final ValueChanged<int> onSelected;
final String tooltip;
final Widget icon;
const SortFilterMenu({
super.key,
required this.sections,
required this.onSelected,
this.tooltip = 'Filter and sort',
this.icon = const Icon(Icons.filter_list_outlined),
});
@override
Widget build(BuildContext context) {
return PopupMenuButton<int>(
icon: icon,
tooltip: tooltip,
onSelected: onSelected,
itemBuilder: (context) {
final theme = Theme.of(context);
final labelStyle = theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
);
final visibleSections = sections.where((section) => section.options.isNotEmpty).toList();
final entries = <PopupMenuEntry<int>>[];
for (int i = 0; i < visibleSections.length; i++) {
final section = visibleSections[i];
entries.add(
PopupMenuItem<int>(
enabled: false,
child: Text(section.title, style: labelStyle),
),
);
for (final option in section.options) {
if (option.checked == null) {
entries.add(
PopupMenuItem<int>(
value: option.value,
child: Text(option.label),
),
);
} else {
entries.add(
CheckedPopupMenuItem<int>(
value: option.value,
checked: option.checked ?? false,
child: Text(option.label),
),
);
}
}
if (i < visibleSections.length - 1) {
entries.add(const PopupMenuDivider());
}
}
return entries;
},
);
}
}
const int _actionSortRecentMessages = 1;
const int _actionSortName = 2;
const int _actionSortLastSeen = 3;
const int _actionFilterAll = 4;
const int _actionFilterUsers = 5;
const int _actionFilterRepeaters = 6;
const int _actionFilterRooms = 7;
const int _actionToggleUnreadOnly = 8;
const int _actionNewGroup = 9;
class ContactsFilterMenu extends StatelessWidget {
final ContactSortOption sortOption;
final ContactTypeFilter typeFilter;
final bool showUnreadOnly;
final ValueChanged<ContactSortOption> onSortChanged;
final ValueChanged<ContactTypeFilter> onTypeFilterChanged;
final ValueChanged<bool> onUnreadOnlyChanged;
final VoidCallback onNewGroup;
const ContactsFilterMenu({
super.key,
required this.sortOption,
required this.typeFilter,
required this.showUnreadOnly,
required this.onSortChanged,
required this.onTypeFilterChanged,
required this.onUnreadOnlyChanged,
required this.onNewGroup,
});
@override
Widget build(BuildContext context) {
return SortFilterMenu(
sections: [
SortFilterMenuSection(
title: 'Sort by',
options: [
SortFilterMenuOption(
value: _actionSortRecentMessages,
label: 'Latest messages',
checked: sortOption == ContactSortOption.recentMessages,
),
SortFilterMenuOption(
value: _actionSortLastSeen,
label: 'Heard recently',
checked: sortOption == ContactSortOption.lastSeen,
),
SortFilterMenuOption(
value: _actionSortName,
label: 'A-Z',
checked: sortOption == ContactSortOption.name,
),
],
),
SortFilterMenuSection(
title: 'Filters',
options: [
SortFilterMenuOption(
value: _actionFilterAll,
label: 'All',
checked: typeFilter == ContactTypeFilter.all,
),
SortFilterMenuOption(
value: _actionFilterUsers,
label: 'Users',
checked: typeFilter == ContactTypeFilter.users,
),
SortFilterMenuOption(
value: _actionFilterRepeaters,
label: 'Repeaters',
checked: typeFilter == ContactTypeFilter.repeaters,
),
SortFilterMenuOption(
value: _actionFilterRooms,
label: 'Room servers',
checked: typeFilter == ContactTypeFilter.rooms,
),
SortFilterMenuOption(
value: _actionToggleUnreadOnly,
label: 'Unread only',
checked: showUnreadOnly,
),
const SortFilterMenuOption(
value: _actionNewGroup,
label: 'New group',
),
],
),
],
onSelected: (action) {
switch (action) {
case _actionSortRecentMessages:
onSortChanged(ContactSortOption.recentMessages);
break;
case _actionSortName:
onSortChanged(ContactSortOption.name);
break;
case _actionSortLastSeen:
onSortChanged(ContactSortOption.lastSeen);
break;
case _actionFilterAll:
onTypeFilterChanged(ContactTypeFilter.all);
break;
case _actionFilterUsers:
onTypeFilterChanged(ContactTypeFilter.users);
break;
case _actionFilterRepeaters:
onTypeFilterChanged(ContactTypeFilter.repeaters);
break;
case _actionFilterRooms:
onTypeFilterChanged(ContactTypeFilter.rooms);
break;
case _actionToggleUnreadOnly:
onUnreadOnlyChanged(!showUnreadOnly);
break;
case _actionNewGroup:
onNewGroup();
break;
}
},
);
}
}
+312
View File
@@ -0,0 +1,312 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../models/contact.dart';
import '../services/path_history_service.dart';
import 'path_selection_dialog.dart';
class PathManagementDialog {
static Future<void> show(
BuildContext context, {
required Contact contact,
String title = 'Path Management',
}) {
return showDialog<void>(
context: context,
builder: (context) => _PathManagementDialog(
contact: contact,
title: title,
),
);
}
}
class _PathManagementDialog extends StatelessWidget {
final Contact contact;
final String title;
const _PathManagementDialog({
required this.contact,
required this.title,
});
Contact _resolveContact(MeshCoreConnector connector) {
return connector.contacts.firstWhere(
(c) => c.publicKeyHex == contact.publicKeyHex,
orElse: () => contact,
);
}
String _formatRelativeTime(DateTime time) {
final diff = DateTime.now().difference(time);
if (diff.inSeconds < 60) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
return '${diff.inDays}d ago';
}
void _showFullPathDialog(BuildContext context, List<int> pathBytes) {
if (pathBytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Path details not available yet. Try sending a message to refresh.'),
duration: Duration(seconds: 2),
),
);
return;
}
final formattedPath = pathBytes
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(',');
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Full Path'),
content: SelectableText(formattedPath),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
Future<void> _setCustomPath(
BuildContext context,
MeshCoreConnector connector,
Contact currentContact,
) async {
if (currentContact.pathLength > 0 && currentContact.path.isEmpty && connector.isConnected) {
connector.getContacts();
}
final pathForInput = currentContact.pathIdList;
final availableContacts = connector.contacts
.where((c) => c.publicKeyHex != currentContact.publicKeyHex)
.toList();
final result = await PathSelectionDialog.show(
context,
availableContacts: availableContacts,
initialPath: pathForInput.isEmpty ? null : pathForInput,
title: 'Set Custom Path',
currentPathLabel: currentContact.pathLabel,
onRefresh: connector.isConnected ? connector.getContacts : null,
);
if (result != null && context.mounted) {
await connector.setPathOverride(
currentContact,
pathLen: result.length,
pathBytes: result,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Path set: ${result.length} ${result.length == 1 ? "hop" : "hops"}'),
duration: const Duration(seconds: 2),
),
);
}
}
@override
Widget build(BuildContext context) {
return Consumer2<MeshCoreConnector, PathHistoryService>(
builder: (context, connector, pathService, _) {
final currentContact = _resolveContact(connector);
final paths = pathService.getRecentPaths(currentContact.publicKeyHex);
return AlertDialog(
title: Text(title),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Current path: ${currentContact.pathLabel}',
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 12),
if (paths.isNotEmpty) ...[
const Text(
'Recent ACK Paths (tap to use):',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
if (paths.length >= 100) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.amberAccent,
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'Path history is full. Remove entries to add new ones.',
style: TextStyle(fontSize: 12),
),
),
],
const SizedBox(height: 8),
...paths.map((path) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
dense: true,
leading: CircleAvatar(
radius: 16,
backgroundColor: path.wasFloodDiscovery ? Colors.blue : Colors.green,
child: Text(
'${path.hopCount}',
style: const TextStyle(fontSize: 12),
),
),
title: Text(
'${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'}',
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(path.timestamp)}${path.successCount} successes',
style: const TextStyle(fontSize: 11),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.close, size: 16),
tooltip: 'Remove path',
onPressed: () async {
await pathService.removePathRecord(
currentContact.publicKeyHex,
path.pathBytes,
);
},
),
path.wasFloodDiscovery
? const Icon(Icons.waves, size: 16, color: Colors.grey)
: const Icon(Icons.route, size: 16, color: Colors.grey),
],
),
onLongPress: () => _showFullPathDialog(context, path.pathBytes),
onTap: () async {
if (path.pathBytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Path details not available yet. Try sending a message to refresh.'),
duration: Duration(seconds: 2),
),
);
return;
}
final pathBytes = Uint8List.fromList(path.pathBytes);
final pathLength = path.pathBytes.length;
await connector.setPathOverride(
currentContact,
pathLen: pathLength,
pathBytes: pathBytes,
);
if (!context.mounted) return;
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Using ${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'} path'),
duration: const Duration(seconds: 2),
),
);
},
),
);
}),
const Divider(),
] else ...[
const Text('No path history yet.\nSend a message to discover paths.'),
const Divider(),
],
const SizedBox(height: 8),
const Text(
'Path Actions:',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
const SizedBox(height: 8),
ListTile(
dense: true,
leading: const CircleAvatar(
radius: 16,
backgroundColor: Colors.purple,
child: Icon(Icons.edit_road, size: 16),
),
title: const Text('Set Custom Path', style: TextStyle(fontSize: 14)),
subtitle: const Text('Manually specify routing path', style: TextStyle(fontSize: 11)),
onTap: () async {
await _setCustomPath(context, connector, currentContact);
},
),
ListTile(
dense: true,
leading: const CircleAvatar(
radius: 16,
backgroundColor: Colors.orange,
child: Icon(Icons.clear_all, size: 16),
),
title: const Text('Clear Path', style: TextStyle(fontSize: 14)),
subtitle: const Text('Force rediscovery on next send', style: TextStyle(fontSize: 11)),
onTap: () async {
await connector.clearContactPath(currentContact);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Path cleared. Next message will rediscover route.'),
duration: Duration(seconds: 2),
),
);
Navigator.pop(context);
},
),
ListTile(
dense: true,
leading: const CircleAvatar(
radius: 16,
backgroundColor: Colors.blue,
child: Icon(Icons.waves, size: 16),
),
title: const Text('Force Flood Mode', style: TextStyle(fontSize: 14)),
subtitle: const Text('Use routing toggle in app bar', style: TextStyle(fontSize: 11)),
onTap: () async {
await connector.setPathOverride(currentContact, pathLen: -1);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Flood mode enabled. Toggle back via routing icon in app bar.'),
duration: Duration(seconds: 2),
),
);
Navigator.pop(context);
},
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
);
},
);
}
}
+312
View File
@@ -0,0 +1,312 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import '../models/contact.dart';
class PathSelectionDialog extends StatefulWidget {
final List<Contact> availableContacts;
final String? initialPath;
final String title;
final String? currentPathLabel;
final VoidCallback? onRefresh;
const PathSelectionDialog({
super.key,
required this.availableContacts,
this.initialPath,
this.title = 'Enter Custom Path',
this.currentPathLabel,
this.onRefresh,
});
@override
State<PathSelectionDialog> createState() => _PathSelectionDialogState();
static Future<Uint8List?> show(
BuildContext context, {
required List<Contact> availableContacts,
String? initialPath,
String title = 'Enter Custom Path',
String? currentPathLabel,
VoidCallback? onRefresh,
}) {
return showDialog<Uint8List?>(
context: context,
builder: (context) => PathSelectionDialog(
availableContacts: availableContacts,
initialPath: initialPath,
title: title,
currentPathLabel: currentPathLabel,
onRefresh: onRefresh,
),
);
}
}
class _PathSelectionDialogState extends State<PathSelectionDialog> {
late TextEditingController _controller;
final List<Contact> _selectedContacts = [];
List<Contact> _validContacts = [];
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialPath ?? '');
_filterValidContacts();
}
@override
void didUpdateWidget(PathSelectionDialog oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.availableContacts != oldWidget.availableContacts) {
_filterValidContacts();
}
}
void _filterValidContacts() {
_validContacts = widget.availableContacts
.where((c) => c.type == 2 || c.type == 3)
.toList();
}
void _updateTextFromContacts() {
final pathParts = _selectedContacts.map((contact) {
if (contact.publicKeyHex.length >= 2) {
return contact.publicKeyHex.substring(0, 2);
}
return '';
}).where((s) => s.isNotEmpty).toList();
_controller.text = pathParts.join(',');
}
void _toggleContact(Contact contact) {
setState(() {
if (_selectedContacts.contains(contact)) {
_selectedContacts.remove(contact);
} else {
_selectedContacts.add(contact);
}
_updateTextFromContacts();
});
}
void _clearSelection() {
setState(() {
_selectedContacts.clear();
_controller.clear();
});
}
Future<void> _validateAndSubmit() async {
final path = _controller.text.trim().toUpperCase();
if (path.isEmpty) {
if (mounted) Navigator.pop(context);
return;
}
// Parse comma-separated hex prefixes
final pathIds = path.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
final pathBytesList = <int>[];
final invalidPrefixes = <String>[];
for (final id in pathIds) {
if (id.length < 2) {
invalidPrefixes.add(id);
continue;
}
final prefix = id.substring(0, 2);
try {
final byte = int.parse(prefix, radix: 16);
pathBytesList.add(byte);
} catch (e) {
invalidPrefixes.add(id);
}
}
if (!mounted) return;
// Show error for invalid prefixes
if (invalidPrefixes.isNotEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Invalid hex prefixes: ${invalidPrefixes.join(", ")}'),
duration: const Duration(seconds: 3),
backgroundColor: Colors.red,
),
);
return;
}
// Check max path length (64 hops)
if (pathBytesList.length > 64) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Path too long. Maximum 64 hops allowed.'),
duration: Duration(seconds: 3),
backgroundColor: Colors.red,
),
);
return;
}
if (pathBytesList.isNotEmpty && mounted) {
Navigator.pop(context, Uint8List.fromList(pathBytesList));
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.title),
content: SingleChildScrollView(
child: SizedBox(
width: double.maxFinite,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.currentPathLabel != null) ...[
Row(
children: [
const Text(
'Current path',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
),
const Spacer(),
if (widget.onRefresh != null)
TextButton.icon(
onPressed: widget.onRefresh,
icon: const Icon(Icons.refresh, size: 16),
label: const Text('Reload'),
),
],
),
Text(
widget.currentPathLabel!,
style: const TextStyle(fontSize: 11, color: Colors.grey),
),
const SizedBox(height: 16),
],
const Text(
'Enter 2-character hex prefixes for each hop, separated by commas.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 8),
const Text(
'Example: A1,F2,3C (each node uses first byte of its public key)',
style: TextStyle(fontSize: 11, color: Colors.grey),
),
const SizedBox(height: 16),
TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'Path (hex prefixes)',
hintText: 'A1,F2,3C',
border: OutlineInputBorder(),
helperText: 'Max 64 hops. Each prefix is 2 hex characters (1 byte)',
),
textCapitalization: TextCapitalization.characters,
maxLength: 191, // 64 hops * 2 chars + 63 commas
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
Row(
children: [
const Text(
'Or select from contacts:',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
),
const Spacer(),
if (_selectedContacts.isNotEmpty)
TextButton(
onPressed: _clearSelection,
child: const Text('Clear'),
),
],
),
const SizedBox(height: 8),
if (_validContacts.isEmpty) ...[
const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Column(
children: [
Icon(Icons.info_outline, size: 48, color: Colors.grey),
SizedBox(height: 16),
Text(
'No repeaters or room servers found.',
style: TextStyle(fontSize: 14),
textAlign: TextAlign.center,
),
SizedBox(height: 8),
Text(
'Custom paths require intermediate hops that can relay messages.',
style: TextStyle(fontSize: 12, color: Colors.grey),
textAlign: TextAlign.center,
),
],
),
),
),
] else ...[
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200),
child: ListView.builder(
shrinkWrap: true,
itemCount: _validContacts.length,
itemBuilder: (context, index) {
final contact = _validContacts[index];
final isSelected = _selectedContacts.contains(contact);
return ListTile(
dense: true,
leading: CircleAvatar(
radius: 16,
backgroundColor: isSelected
? Colors.green
: (contact.type == 2 ? Colors.blue : Colors.purple),
child: Icon(
contact.type == 2 ? Icons.router : Icons.meeting_room,
size: 16,
color: Colors.white,
),
),
title: Text(contact.name, style: const TextStyle(fontSize: 14)),
subtitle: Text(
'${contact.typeLabel}${contact.publicKeyHex.substring(0, 2)}',
style: const TextStyle(fontSize: 10),
),
trailing: isSelected
? const Icon(Icons.check_circle, color: Colors.green)
: const Icon(Icons.add_circle_outline),
onTap: () => _toggleContact(contact),
);
},
),
),
],
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: _validateAndSubmit,
child: const Text('Set Path'),
),
],
);
}
}
+140 -9
View File
@@ -5,9 +5,10 @@ import 'package:flutter/foundation.dart';
import 'package:provider/provider.dart';
import '../models/contact.dart';
import '../services/storage_service.dart';
import '../services/repeater_command_service.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../utils/app_logger.dart';
import 'path_management_dialog.dart';
class RepeaterLoginDialog extends StatefulWidget {
final Contact repeater;
@@ -31,8 +32,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
bool _obscurePassword = true;
late MeshCoreConnector _connector;
int _currentAttempt = 0;
final int _maxAttempts = RepeaterCommandService.maxRetries;
static const int _loginTimeoutSeconds = 10;
static const int _maxAttempts = 5;
@override
void initState() {
@@ -65,6 +65,13 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
bool _isLoggingIn = false;
Contact _resolveRepeater(MeshCoreConnector connector) {
return connector.contacts.firstWhere(
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
orElse: () => widget.repeater,
);
}
Future<void> _handleLogin() async {
if (_isLoggingIn) return;
@@ -75,6 +82,26 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
try {
final password = _passwordController.text;
final repeater = _resolveRepeater(_connector);
appLogger.info(
'Login started for ${repeater.name} (${repeater.publicKeyHex})',
tag: 'RepeaterLogin',
);
final selection = await _connector.preparePathForContactSend(repeater);
final loginFrame = buildSendLoginFrame(repeater.publicKey, password);
final pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
final timeoutMs = _connector.calculateTimeout(
pathLength: pathLengthValue,
messageBytes: loginFrame.length,
);
final timeoutSeconds = (timeoutMs / 1000).ceil();
final timeout = Duration(milliseconds: timeoutMs);
final selectionLabel =
selection.useFlood ? 'flood' : '${selection.hopCount} hops';
appLogger.info(
'Login routing: $selectionLabel',
tag: 'RepeaterLogin',
);
bool? loginResult;
for (int attempt = 0; attempt < _maxAttempts; attempt++) {
if (!mounted) return;
@@ -82,17 +109,46 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
_currentAttempt = attempt + 1;
});
appLogger.info(
'Sending login attempt ${attempt + 1}/$_maxAttempts',
tag: 'RepeaterLogin',
);
await _connector.sendFrame(
buildSendLoginFrame(widget.repeater.publicKey, password),
loginFrame,
);
loginResult = await _awaitLoginResponse();
loginResult = await _awaitLoginResponse(timeout);
if (loginResult == true) {
appLogger.info(
'Login succeeded for ${repeater.name}',
tag: 'RepeaterLogin',
);
break;
}
if (loginResult == false) {
appLogger.warn(
'Login failed for ${repeater.name}',
tag: 'RepeaterLogin',
);
throw Exception('Wrong password or node is unreachable');
}
appLogger.warn(
'Login attempt ${attempt + 1} timed out after ${timeoutSeconds}s',
tag: 'RepeaterLogin',
);
}
if (loginResult == null) {
appLogger.warn(
'Login timed out for ${repeater.name}',
tag: 'RepeaterLogin',
);
}
if (loginResult == true) {
_connector.recordRepeaterPathResult(repeater, selection, true, null);
} else {
_connector.recordRepeaterPathResult(repeater, selection, false, null);
}
if (loginResult != true) {
@@ -114,6 +170,11 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
Future.microtask(() => widget.onLogin(password));
}
} catch (e) {
final repeater = _resolveRepeater(_connector);
appLogger.warn(
'Login error for ${repeater.name}: $e',
tag: 'RepeaterLogin',
);
if (mounted) {
setState(() {
_isLoggingIn = false;
@@ -128,7 +189,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
}
}
Future<bool?> _awaitLoginResponse() async {
Future<bool?> _awaitLoginResponse(Duration timeout) async {
final completer = Completer<bool?>();
Timer? timer;
StreamSubscription<Uint8List>? subscription;
@@ -147,7 +208,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
timer?.cancel();
});
timer = Timer(const Duration(seconds: _loginTimeoutSeconds), () {
timer = Timer(timeout, () {
if (!completer.isCompleted) {
completer.complete(null);
subscription?.cancel();
@@ -162,6 +223,9 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
@override
Widget build(BuildContext context) {
final connector = context.watch<MeshCoreConnector>();
final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1;
return AlertDialog(
title: Row(
children: [
@@ -173,7 +237,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
children: [
const Text('Repeater Login'),
Text(
widget.repeater.name,
repeater.name,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
@@ -244,6 +308,73 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
),
const Divider(),
Row(
children: [
const Text(
'Routing',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
),
const Spacer(),
PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: 'Routing mode',
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1);
} else {
await connector.setPathOverride(repeater, pathLen: null);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'auto',
child: Row(
children: [
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Auto (use saved path)',
style: TextStyle(
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: 'flood',
child: Row(
children: [
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Force Flood Mode',
style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
),
),
],
),
),
],
),
],
),
const SizedBox(height: 4),
Text(
repeater.pathLabel,
style: const TextStyle(fontSize: 11, color: Colors.grey),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () => PathManagementDialog.show(context, contact: repeater),
icon: const Icon(Icons.timeline, size: 18),
label: const Text('Manage Paths'),
),
),
],
),
actions: [
@@ -268,7 +399,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
),
),
const SizedBox(width: 12),
Text('Retries $_currentAttempt/$_maxAttempts'),
Text('Attempt $_currentAttempt/$_maxAttempts'),
],
),
),