Add nearest relay fallback toggle

This commit is contained in:
Janez T
2026-03-08 16:56:57 +01:00
parent 922b40b755
commit 6b2f18130c
7 changed files with 161 additions and 0 deletions

View File

@@ -98,6 +98,9 @@ class AppProvider with ChangeNotifier {
bool _clearPathOnMaxRetry = bool _clearPathOnMaxRetry =
MessagingRoutePreferences.defaultClearPathOnMaxRetry; MessagingRoutePreferences.defaultClearPathOnMaxRetry;
bool get clearPathOnMaxRetry => _clearPathOnMaxRetry; bool get clearPathOnMaxRetry => _clearPathOnMaxRetry;
bool _nearestRelayFallbackEnabled =
MessagingRoutePreferences.defaultNearestRelayFallbackEnabled;
bool get nearestRelayFallbackEnabled => _nearestRelayFallbackEnabled;
final PathHistoryService _pathHistoryService = PathHistoryService(); final PathHistoryService _pathHistoryService = PathHistoryService();
final NearestRouterSelector _nearestRouterSelector = final NearestRouterSelector _nearestRouterSelector =
const NearestRouterSelector(); const NearestRouterSelector();
@@ -458,6 +461,8 @@ class AppProvider with ChangeNotifier {
await MessagingRoutePreferences.getAutoRouteRotationEnabled(); await MessagingRoutePreferences.getAutoRouteRotationEnabled();
_clearPathOnMaxRetry = _clearPathOnMaxRetry =
await MessagingRoutePreferences.getClearPathOnMaxRetry(); await MessagingRoutePreferences.getClearPathOnMaxRetry();
_nearestRelayFallbackEnabled =
await MessagingRoutePreferences.getNearestRelayFallbackEnabled();
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('Error loading messaging route settings: $e'); debugPrint('Error loading messaging route settings: $e');
@@ -484,6 +489,16 @@ class AppProvider with ChangeNotifier {
} }
} }
Future<void> toggleNearestRelayFallbackEnabled(bool enabled) async {
try {
_nearestRelayFallbackEnabled = enabled;
await MessagingRoutePreferences.setNearestRelayFallbackEnabled(enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving nearest relay fallback setting: $e');
}
}
/// Initialize location tracking service /// Initialize location tracking service
Future<void> _initializeLocationTracking() async { Future<void> _initializeLocationTracking() async {
try { try {
@@ -713,6 +728,19 @@ class AppProvider with ChangeNotifier {
final receptionDetailsSnapshot = _buildReceptionDetailsSnapshot( final receptionDetailsSnapshot = _buildReceptionDetailsSnapshot(
enrichedMessage, enrichedMessage,
); );
final receivedPathBytes = receptionDetailsSnapshot?.pathBytes;
if (senderContact != null &&
enrichedMessage.isChannelMessage &&
(enrichedMessage.channelIdx ?? 0) == 0 &&
receivedPathBytes != null &&
receivedPathBytes.isNotEmpty) {
unawaited(
_learnPathFromPublicMessage(
contact: senderContact,
pathBytes: receivedPathBytes,
),
);
}
// Check if message is a drawing broadcast // Check if message is a drawing broadcast
if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) { if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) {
@@ -1379,6 +1407,10 @@ class AppProvider with ChangeNotifier {
required Contact contact, required Contact contact,
required Message message, required Message message,
}) async { }) async {
if (!_nearestRelayFallbackEnabled) {
return false;
}
final latestContact = final latestContact =
contactsProvider.findContactByKey(contact.publicKey) ?? contact; contactsProvider.findContactByKey(contact.publicKey) ?? contact;
final session = final session =
@@ -1491,6 +1523,38 @@ class AppProvider with ChangeNotifier {
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
} }
Future<void> _learnPathFromPublicMessage({
required Contact contact,
required List<int> pathBytes,
}) async {
final preferred = await RouteHashPreferences.getHashSize();
final inferredHashSize = _inferReceivedPathHashSize(
pathBytes,
preferredHashSize: preferred,
);
await _pathHistoryService.recordReceivedBytePath(
contact.publicKeyHex,
pathBytes,
inferredHashSize,
);
}
int _inferReceivedPathHashSize(
List<int> pathBytes, {
required int preferredHashSize,
}) {
final preferred = preferredHashSize;
final candidates = {preferred, 3, 2, 1}.toList();
for (final candidate in candidates) {
if (candidate >= 1 &&
candidate <= 3 &&
pathBytes.length % candidate == 0) {
return candidate;
}
}
return 1;
}
/// Initialize the app (load contacts, sync time, etc.) /// Initialize the app (load contacts, sync time, etc.)
Future<void> initialize() async { Future<void> initialize() async {
if (!connectionProvider.deviceInfo.isConnected) return; if (!connectionProvider.deviceInfo.isConnected) return;

View File

@@ -988,6 +988,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
}, },
), ),
), ),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.route),
title: const Text('Nearest repeater fallback'),
subtitle: const Text(
'After normal retries fail, try one final resend through the nearest repeater',
),
value: appProvider.nearestRelayFallbackEnabled,
onChanged: (value) async {
await appProvider.toggleNearestRelayFallbackEnabled(value);
},
),
),
Consumer<AppProvider>( Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile( builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.route), secondary: const Icon(Icons.route),

View File

@@ -3,11 +3,14 @@ import 'package:shared_preferences/shared_preferences.dart';
class MessagingRoutePreferences { class MessagingRoutePreferences {
static const bool defaultAutoRouteRotationEnabled = false; static const bool defaultAutoRouteRotationEnabled = false;
static const bool defaultClearPathOnMaxRetry = false; static const bool defaultClearPathOnMaxRetry = false;
static const bool defaultNearestRelayFallbackEnabled = true;
static const String _autoRouteRotationKey = static const String _autoRouteRotationKey =
'messaging_auto_route_rotation_enabled'; 'messaging_auto_route_rotation_enabled';
static const String _clearPathOnMaxRetryKey = static const String _clearPathOnMaxRetryKey =
'messaging_clear_path_on_max_retry'; 'messaging_clear_path_on_max_retry';
static const String _nearestRelayFallbackKey =
'messaging_nearest_relay_fallback_enabled';
static Future<bool> getAutoRouteRotationEnabled() async { static Future<bool> getAutoRouteRotationEnabled() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -29,4 +32,15 @@ class MessagingRoutePreferences {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_clearPathOnMaxRetryKey, enabled); await prefs.setBool(_clearPathOnMaxRetryKey, enabled);
} }
static Future<bool> getNearestRelayFallbackEnabled() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_nearestRelayFallbackKey) ??
defaultNearestRelayFallbackEnabled;
}
static Future<void> setNearestRelayFallbackEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_nearestRelayFallbackKey, enabled);
}
} }

View File

@@ -67,6 +67,45 @@ class PathHistoryService {
); );
} }
Future<void> recordReceivedBytePath(
String contactPublicKeyHex,
List<int> pathBytes,
int hashSize,
) async {
await initialize();
if (pathBytes.isEmpty) {
return;
}
if (hashSize < 1 || hashSize > 3) {
return;
}
if (pathBytes.length % hashSize != 0) {
return;
}
final history = _historyFor(contactPublicKeyHex);
final signature = pathBytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
final existing = _findDirectPath(history.directPaths, signature);
final updated = PathRecord(
pathBytes: List<int>.from(pathBytes),
hopCount: pathBytes.length ~/ hashSize,
hashSize: hashSize,
successCount: existing?.successCount ?? 0,
failureCount: existing?.failureCount ?? 0,
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
lastUsedAt: DateTime.now(),
);
await _saveHistory(
contactPublicKeyHex,
history.copyWith(
directPaths: _upsertDirectPath(history.directPaths, updated),
),
);
}
Future<PathSelection> getSelectionForContact( Future<PathSelection> getSelectionForContact(
Contact contact, { Contact contact, {
required bool autoRouteRotationEnabled, required bool autoRouteRotationEnabled,

View File

@@ -196,6 +196,8 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
}); });
}, },
autoRouteRotationEnabled: appProvider.autoRouteRotationEnabled, autoRouteRotationEnabled: appProvider.autoRouteRotationEnabled,
nearestRelayFallbackEnabled:
appProvider.nearestRelayFallbackEnabled,
clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry, clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -264,12 +266,14 @@ class _AutomationRoutingInfo extends StatelessWidget {
final bool isExpanded; final bool isExpanded;
final VoidCallback onToggle; final VoidCallback onToggle;
final bool autoRouteRotationEnabled; final bool autoRouteRotationEnabled;
final bool nearestRelayFallbackEnabled;
final bool clearPathOnMaxRetry; final bool clearPathOnMaxRetry;
const _AutomationRoutingInfo({ const _AutomationRoutingInfo({
required this.isExpanded, required this.isExpanded,
required this.onToggle, required this.onToggle,
required this.autoRouteRotationEnabled, required this.autoRouteRotationEnabled,
required this.nearestRelayFallbackEnabled,
required this.clearPathOnMaxRetry, required this.clearPathOnMaxRetry,
}); });
@@ -336,6 +340,12 @@ class _AutomationRoutingInfo extends StatelessWidget {
: 'Auto route rotation off', : 'Auto route rotation off',
icon: Icons.swap_horiz, icon: Icons.swap_horiz,
), ),
_InfoChip(
label: nearestRelayFallbackEnabled
? 'Nearest repeater fallback on'
: 'Nearest repeater fallback off',
icon: Icons.router,
),
_InfoChip( _InfoChip(
label: clearPathOnMaxRetry label: clearPathOnMaxRetry
? 'Clear path on max retry on' ? 'Clear path on max retry on'

View File

@@ -16,16 +16,25 @@ void main() {
isFalse, isFalse,
); );
expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isFalse); expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isFalse);
expect(
await MessagingRoutePreferences.getNearestRelayFallbackEnabled(),
isTrue,
);
}); });
test('route preferences persist changes', () async { test('route preferences persist changes', () async {
await MessagingRoutePreferences.setAutoRouteRotationEnabled(true); await MessagingRoutePreferences.setAutoRouteRotationEnabled(true);
await MessagingRoutePreferences.setClearPathOnMaxRetry(true); await MessagingRoutePreferences.setClearPathOnMaxRetry(true);
await MessagingRoutePreferences.setNearestRelayFallbackEnabled(false);
expect( expect(
await MessagingRoutePreferences.getAutoRouteRotationEnabled(), await MessagingRoutePreferences.getAutoRouteRotationEnabled(),
isTrue, isTrue,
); );
expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isTrue); expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isTrue);
expect(
await MessagingRoutePreferences.getNearestRelayFallbackEnabled(),
isFalse,
);
}); });
} }

View File

@@ -127,4 +127,16 @@ void main() {
expect(selection.mode, PathSelectionMode.flood); expect(selection.mode, PathSelectionMode.flood);
}); });
test('received public byte path is added to history', () async {
final service = PathHistoryService();
await service.initialize();
await service.recordReceivedBytePath('abc123', [0x01, 0x02, 0x03], 3);
final history = service.historyFor('abc123');
expect(history.directPaths, hasLength(1));
expect(history.directPaths.single.pathBytes, [0x01, 0x02, 0x03]);
expect(history.directPaths.single.hashSize, 3);
expect(history.directPaths.single.hopCount, 1);
});
} }