Implement meshcore-open route reload

This commit is contained in:
Janez T
2026-03-08 14:26:05 +01:00
parent e026c1dbbd
commit 17d7c43745
25 changed files with 1927 additions and 278 deletions

View File

@@ -4,7 +4,10 @@ import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/models/path_selection.dart';
import 'package:meshcore_sar_app/providers/helpers/message_retry_manager.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
Contact _buildContact() {
return Contact(
@@ -39,6 +42,10 @@ Message _buildDirectMessage(String id) {
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
group('MessagesProvider retransmission', () {
test('direct messages become sent before delivery ACK arrives', () {
final provider = MessagesProvider();
@@ -171,7 +178,11 @@ void main() {
);
expect(retryCalls, 0);
async.elapse(const Duration(seconds: 4));
async.elapse(const Duration(milliseconds: 998));
async.flushMicrotasks();
expect(retryCalls, 0);
async.elapse(const Duration(milliseconds: 1));
async.flushMicrotasks();
expect(retryCalls, 1);
@@ -237,68 +248,89 @@ void main() {
expect(provider.messages.single.retryAttempt, 0);
});
test('repeated max-retry failures request path reset', () async {
test(
'final router fallback runs after all normal retries are exhausted',
() async {
final provider = MessagesProvider();
final fallbackCalls = <String>[];
provider.onFinalRouterFallbackCallback =
({required messageId, required contact, required message}) async {
fallbackCalls.add(messageId);
return true;
};
provider.addSentMessage(
_buildDirectMessage(
'm5',
).copyWith(retryAttempt: MessageRetryManager.maxRetryAttempts),
contact: _buildContact(),
);
provider.markMessageFailed('m5');
await Future<void>.delayed(Duration.zero);
expect(fallbackCalls, ['m5']);
expect(
provider.messages.single.deliveryStatus,
MessageDeliveryStatus.sending,
);
},
);
test('final router fallback is not retried twice', () async {
final provider = MessagesProvider();
final contact = _buildContact();
final resetRequests = <(String, int)>[];
provider.onDirectPathFailedCallback =
({required contact, required failureStreak}) async {
resetRequests.add((contact.advName, failureStreak));
};
provider.addSentMessage(
_buildDirectMessage(
'm5',
).copyWith(retryAttempt: 3, usedFloodFallback: true),
contact: contact,
);
provider.markMessageFailed('m5');
provider.addSentMessage(
_buildDirectMessage(
'm6',
).copyWith(retryAttempt: 3, usedFloodFallback: true),
contact: contact,
).copyWith(retryAttempt: MessageRetryManager.maxRetryAttempts),
contact: _buildContact(),
);
provider.updateMessageRouteSelection(
'm6',
PathSelection.flood(),
routerFallbackAttempted: true,
);
provider.markMessageFailed('m6');
await Future<void>.delayed(Duration.zero);
expect(resetRequests, [('Teammate', 2)]);
expect(
provider.messages.single.deliveryStatus,
MessageDeliveryStatus.failed,
);
});
test('successful delivery clears path failure streak', () async {
final provider = MessagesProvider();
final contact = _buildContact();
final resetRequests = <int>[];
provider.onDirectPathFailedCallback =
({required contact, required failureStreak}) async {
resetRequests.add(failureStreak);
};
test(
'final permanent failure callback runs after router fallback failure',
() async {
final provider = MessagesProvider();
final failedMessageIds = <String>[];
provider.onFinalDirectMessageFailureCallback =
({required messageId, required contact, required message}) async {
failedMessageIds.add(messageId);
};
provider.addSentMessage(
_buildDirectMessage(
provider.addSentMessage(
_buildDirectMessage(
'm7',
).copyWith(retryAttempt: MessageRetryManager.maxRetryAttempts),
contact: _buildContact(),
);
provider.updateMessageRouteSelection(
'm7',
).copyWith(retryAttempt: 3, usedFloodFallback: true),
contact: contact,
);
provider.markMessageFailed('m7');
PathSelection.flood(),
routerFallbackAttempted: true,
);
provider.addSentMessage(_buildDirectMessage('m8'), contact: contact);
provider.markMessageSent('m8', 123, 10);
provider.markMessageDelivered(123, 150);
provider.markMessageFailed('m7');
await Future<void>.delayed(Duration.zero);
provider.addSentMessage(
_buildDirectMessage(
'm9',
).copyWith(retryAttempt: 3, usedFloodFallback: true),
contact: contact,
);
provider.markMessageFailed('m9');
await Future<void>.delayed(Duration.zero);
expect(resetRequests, isEmpty);
});
expect(failedMessageIds, ['m7']);
expect(
provider.messages.single.deliveryStatus,
MessageDeliveryStatus.failed,
);
},
);
});
}

View File

@@ -0,0 +1,31 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:meshcore_sar_app/services/messaging_route_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('route preference defaults are disabled', () async {
expect(
await MessagingRoutePreferences.getAutoRouteRotationEnabled(),
isFalse,
);
expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isFalse);
});
test('route preferences persist changes', () async {
await MessagingRoutePreferences.setAutoRouteRotationEnabled(true);
await MessagingRoutePreferences.setClearPathOnMaxRetry(true);
expect(
await MessagingRoutePreferences.getAutoRouteRotationEnabled(),
isTrue,
);
expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isTrue);
});
}

View File

@@ -0,0 +1,115 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:geolocator/geolocator.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/services/nearest_router_selector.dart';
Contact _buildRepeater({
required int seed,
required String name,
required double latitude,
required double longitude,
required int lastAdvert,
int outPathLen = -1,
}) {
return Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i + seed)),
type: ContactType.repeater,
flags: 0,
outPathLen: outPathLen,
outPath: Uint8List(0),
advName: name,
lastAdvert: lastAdvert,
advLat: (latitude * 1e6).round(),
advLon: (longitude * 1e6).round(),
lastMod: lastAdvert,
);
}
Position _position(double latitude, double longitude) {
return Position(
latitude: latitude,
longitude: longitude,
timestamp: DateTime.now(),
accuracy: 1,
altitude: 0,
altitudeAccuracy: 1,
heading: 0,
headingAccuracy: 1,
speed: 0,
speedAccuracy: 0,
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('selector chooses nearest eligible repeater', () {
final selector = NearestRouterSelector();
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final recipient = _buildRepeater(
seed: 90,
name: 'Recipient',
latitude: 46.05,
longitude: 14.50,
lastAdvert: now,
).copyWith(type: ContactType.chat);
final selected = selector.select(
senderPosition: _position(46.0569, 14.5058),
repeaters: [
_buildRepeater(
seed: 1,
name: 'Far',
latitude: 46.10,
longitude: 14.60,
lastAdvert: now,
outPathLen: 1,
),
_buildRepeater(
seed: 2,
name: 'Near',
latitude: 46.0570,
longitude: 14.5060,
lastAdvert: now - 5,
outPathLen: 1,
),
],
recipient: recipient,
);
expect(selected?.advName, 'Near');
});
test('selector skips stale repeaters', () {
final selector = NearestRouterSelector();
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final staleAdvert = now - (11 * 60);
final recipient = _buildRepeater(
seed: 91,
name: 'Recipient',
latitude: 46.05,
longitude: 14.50,
lastAdvert: now,
).copyWith(type: ContactType.chat);
final selected = selector.select(
senderPosition: _position(46.0569, 14.5058),
repeaters: [
_buildRepeater(
seed: 3,
name: 'Stale',
latitude: 46.0570,
longitude: 14.5060,
lastAdvert: staleAdvert,
outPathLen: 1,
),
],
recipient: recipient,
);
expect(selected, isNull);
});
}

View File

@@ -0,0 +1,130 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/path_selection.dart';
import 'package:meshcore_sar_app/services/path_history_service.dart';
Contact _buildContact({
required int seed,
required List<int> pathBytes,
required int hopCount,
required int hashSize,
}) {
final encoded = ((hashSize - 1) << 6) | (hopCount & 0x3F);
final outPath = Uint8List(ContactRouteCodec.maxPathBytes)
..setRange(0, pathBytes.length, pathBytes);
return Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i + seed)),
type: ContactType.chat,
flags: 0,
outPathLen: ContactRouteCodec.toSignedDescriptor(encoded),
outPath: outPath,
advName: 'Contact $seed',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('auto rotation ranks best paths before flood', () async {
final service = PathHistoryService();
final contact = _buildContact(
seed: 0,
pathBytes: [0xAA, 0xBB],
hopCount: 2,
hashSize: 1,
);
final best = PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList([0xAA, 0xBB]),
hopCount: 2,
hashSize: 1,
);
final second = PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList([0xCC, 0xDD]),
hopCount: 2,
hashSize: 1,
);
await service.initialize();
await service.recordLearnedPath(contact);
await service.recordPathResult(
contact.publicKeyHex,
best,
success: true,
roundTripTimeMs: 120,
);
await service.recordPathResult(
contact.publicKeyHex,
best,
success: true,
roundTripTimeMs: 110,
);
await service.recordPathResult(
contact.publicKeyHex,
second,
success: true,
roundTripTimeMs: 200,
);
await service.recordPathResult(
contact.publicKeyHex,
second,
success: false,
);
final first = await service.getSelectionForContact(
contact,
autoRouteRotationEnabled: true,
);
final third = await service.getSelectionForContact(
contact,
autoRouteRotationEnabled: true,
);
final secondPick = await service.getSelectionForContact(
contact,
autoRouteRotationEnabled: true,
);
expect(first.mode, PathSelectionMode.directHistorical);
expect(first.canonicalPath, 'AA,BB');
expect(third.mode, PathSelectionMode.directHistorical);
expect(third.canonicalPath, 'CC,DD');
expect(secondPick.mode, PathSelectionMode.flood);
});
test('no history falls back to flood', () async {
final service = PathHistoryService();
final contact = Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)),
type: ContactType.chat,
flags: 0,
outPathLen: -1,
outPath: Uint8List(0),
advName: 'No Route',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
final selection = await service.getSelectionForContact(
contact,
autoRouteRotationEnabled: true,
);
expect(selection.mode, PathSelectionMode.flood);
});
}