Add swarm mode transport doc

This commit is contained in:
Janez T
2026-03-07 14:13:54 +01:00
parent 4e76898c8d
commit 0e4f727e26
43 changed files with 3831 additions and 1078 deletions

View File

@@ -233,4 +233,49 @@ void main() {
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
});
});
group('ContactsProvider route updates', () {
late ContactsProvider provider;
late Uint8List publicKey;
setUp(() {
SharedPreferences.setMockInitialValues({});
provider = ContactsProvider();
publicKey = createPublicKey(64);
provider.addOrUpdateContact(
createContact(key: publicKey, type: ContactType.chat, name: 'Routey'),
);
});
test('optimistically stores a multi-byte route locally', () {
final route = ContactRouteCodec.parse('AABB,CCDD');
provider.setContactRouteLocal(
publicKey,
signedEncodedPathLen: route.signedEncodedPathLen,
paddedPathBytes: route.paddedPathBytes,
);
final updated = provider.findContactByKey(publicKey)!;
expect(updated.routeHasPath, isTrue);
expect(updated.routeHashSize, 2);
expect(updated.routeHopCount, 2);
expect(updated.routeCanonicalText, 'AABB,CCDD');
});
test('resetContactRouteLocal clears route state', () {
final route = ContactRouteCodec.parse('AA,BB,CC');
provider.setContactRouteLocal(
publicKey,
signedEncodedPathLen: route.signedEncodedPathLen,
paddedPathBytes: route.paddedPathBytes,
);
provider.resetContactRouteLocal(publicKey);
final updated = provider.findContactByKey(publicKey)!;
expect(updated.routeHasPath, isFalse);
expect(updated.routeSummary, 'Flood/Unknown');
});
});
}

View File

@@ -1,4 +1,7 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/providers/helpers/session_metadata_restore.dart';
import 'package:meshcore_sar_app/utils/image_message_parser.dart';
import 'package:meshcore_sar_app/utils/voice_message_parser.dart';
@@ -13,8 +16,6 @@ void main() {
mode: VoicePacketMode.mode1200,
total: 4,
durationMs: 4000,
senderKey6: 'AABBCCDDEEFF',
timestampSec: 123456,
);
final imageEnvelope = ImageEnvelope(
sessionId: '195cb2fb',
@@ -23,24 +24,62 @@ void main() {
width: 118,
height: 256,
sizeBytes: 1069,
senderKey6: 'FE8B30EE05FC',
timestampSec: 123457,
);
final restored = restoreSessionMetadataFromMessages([
'plain text',
voiceEnvelope.encodeText(),
imageEnvelope.encode(),
Message(
id: 'plain',
messageType: MessageType.channel,
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1,
text: 'plain text',
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.sent,
),
Message(
id: 'voice',
messageType: MessageType.channel,
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 2,
text: voiceEnvelope.encodeText(),
receivedAt: DateTime.now(),
senderPublicKeyPrefix: Uint8List.fromList(
[0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff],
),
deliveryStatus: MessageDeliveryStatus.sent,
),
Message(
id: 'image',
messageType: MessageType.channel,
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 3,
text: imageEnvelope.encode(),
receivedAt: DateTime.now(),
senderPublicKeyPrefix: Uint8List.fromList(
[0xfe, 0x8b, 0x30, 0xee, 0x05, 0xfc],
),
deliveryStatus: MessageDeliveryStatus.sent,
),
]);
expect(
restored.voiceSenderKeyBySession,
equals({'00112233': 'aabbccddeeff'}),
);
expect(
restored.imageSenderKeyBySession,
equals({'195cb2fb': 'fe8b30ee05fc'}),
);
expect(restored.imageEnvelopeBySession.keys, equals({'195cb2fb'}));
expect(
restored.imageEnvelopeBySession['195cb2fb']?.senderKey6,
equals('fe8b30ee05fc'),
restored.imageEnvelopeBySession['195cb2fb']?.sessionId,
equals('195cb2fb'),
);
},
);
@@ -53,8 +92,6 @@ void main() {
width: 100,
height: 100,
sizeBytes: 900,
senderKey6: '001122334455',
timestampSec: 100,
);
final second = ImageEnvelope(
sessionId: '195cb2fb',
@@ -63,18 +100,40 @@ void main() {
width: 118,
height: 256,
sizeBytes: 1069,
senderKey6: 'AABBCCDDEEFF',
timestampSec: 101,
);
final restored = restoreSessionMetadataFromMessages([
first.encode(),
second.encode(),
Message(
id: 'first',
messageType: MessageType.channel,
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1,
text: first.encode(),
receivedAt: DateTime.now(),
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
deliveryStatus: MessageDeliveryStatus.sent,
),
Message(
id: 'second',
messageType: MessageType.channel,
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 2,
text: second.encode(),
receivedAt: DateTime.now(),
senderPublicKeyPrefix: Uint8List.fromList(
[0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff],
),
deliveryStatus: MessageDeliveryStatus.sent,
),
]);
expect(restored.imageEnvelopeBySession.length, equals(1));
expect(
restored.imageEnvelopeBySession['195cb2fb']?.senderKey6,
restored.imageSenderKeyBySession['195cb2fb'],
equals('aabbccddeeff'),
);
expect(

View File

@@ -20,8 +20,6 @@ void main() {
width: 32,
height: 32,
sizeBytes: 4,
senderKey6: 'aabbccddeeff',
timestampSec: 1700000000,
);
final fragment = ImagePacket(
sessionId: sessionId,

View File

@@ -0,0 +1,90 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/providers/image_provider.dart';
import 'package:meshcore_sar_app/utils/image_message_parser.dart';
import 'package:shared_preferences/shared_preferences.dart';
Contact _buildRequester() {
return Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)),
type: ContactType.chat,
flags: 0,
outPathLen: 1,
outPath: Uint8List.fromList([1, 2, 3, 4]),
advName: 'Requester',
lastAdvert: 1700000000,
advLat: 0,
advLon: 0,
lastMod: 1700000000,
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('ImageProvider swarm serving', () {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('serves requested fragments from received session cache', () async {
final provider = ImageProvider();
provider.registerEnvelope(
const ImageEnvelope(
sessionId: 'deadbeef',
format: ImageFormat.avif,
total: 3,
width: 64,
height: 64,
sizeBytes: 300,
),
);
provider.addFragment(
ImagePacket(
sessionId: 'deadbeef',
format: ImageFormat.avif,
index: 0,
total: 3,
data: Uint8List.fromList([1, 2]),
),
width: 64,
height: 64,
);
provider.addFragment(
ImagePacket(
sessionId: 'deadbeef',
format: ImageFormat.avif,
index: 2,
total: 3,
data: Uint8List.fromList([7, 8]),
),
width: 64,
height: 64,
);
final sent = <Uint8List>[];
provider.sendRawPacketCallback =
({
required contactPath,
required contactPathLen,
required payload,
}) async {
sent.add(payload);
};
final ok = await provider.serveSessionTo(
sessionId: 'deadbeef',
requester: _buildRequester(),
requestedIndices: {2},
);
expect(ok, isTrue);
expect(provider.availableFragmentIndices('deadbeef'), [0, 2]);
expect(sent, hasLength(1));
expect(ImagePacket.tryParseBinary(sent.single)?.index, 2);
});
});
}

View File

@@ -64,22 +64,54 @@ void main() {
expect(provider.messages.single.roundTripTimeMs, 180);
});
test('direct messages stay sent after device accept until confirm arrives', () {
test(
'direct messages stay sent after device accept until confirm arrives',
() {
final provider = MessagesProvider();
provider.addSentMessage(
_buildDirectMessage('m1b'),
contact: _buildContact(),
);
provider.markMessageSent('m1b', 78, 250);
expect(
provider.messages.single.deliveryStatus,
MessageDeliveryStatus.sent,
);
expect(provider.messages.single.expectedAckTag, 78);
expect(provider.messages.single.roundTripTimeMs, isNull);
expect(provider.messages.single.deliveredAt, isNull);
},
);
test('fallback sent state can later upgrade to ACK-tracked delivery', () {
final provider = MessagesProvider();
provider.addSentMessage(
_buildDirectMessage('m1b'),
_buildDirectMessage('m1c'),
contact: _buildContact(),
);
provider.markMessageSent('m1b', 78, 250);
provider.markMessageSent('m1c', 0, 0);
expect(
provider.messages.single.deliveryStatus,
MessageDeliveryStatus.sent,
);
expect(provider.messages.single.expectedAckTag, 78);
expect(provider.messages.single.roundTripTimeMs, isNull);
expect(provider.messages.single.deliveredAt, isNull);
expect(provider.messages.single.expectedAckTag, isNull);
provider.markMessageSent('m1c', 79, 250);
expect(
provider.messages.single.deliveryStatus,
MessageDeliveryStatus.sent,
);
expect(provider.messages.single.expectedAckTag, 79);
provider.markMessageDelivered(79, 190);
expect(
provider.messages.single.deliveryStatus,
MessageDeliveryStatus.delivered,
);
expect(provider.messages.single.roundTripTimeMs, 190);
});
test('channel messages are marked sent immediately', () {
@@ -180,6 +212,31 @@ void main() {
expect(provider.messages.single.roundTripTimeMs, 220);
});
test('manual retry reuses the same message record', () {
final provider = MessagesProvider();
provider.addSentMessage(
_buildDirectMessage('m4b'),
contact: _buildContact(),
);
provider.markMessageSent('m4b', 113, 10);
provider.markMessageDelivered(113, 220);
final prepared = provider.prepareMessageForRetry('m4b');
expect(prepared, isTrue);
expect(provider.messages, hasLength(1));
expect(provider.messages.single.id, 'm4b');
expect(
provider.messages.single.deliveryStatus,
MessageDeliveryStatus.sending,
);
expect(provider.messages.single.expectedAckTag, isNull);
expect(provider.messages.single.roundTripTimeMs, isNull);
expect(provider.messages.single.deliveredAt, isNull);
expect(provider.messages.single.retryAttempt, 0);
});
test('repeated max-retry failures request path reset', () async {
final provider = MessagesProvider();
final contact = _buildContact();
@@ -190,19 +247,17 @@ void main() {
};
provider.addSentMessage(
_buildDirectMessage('m5').copyWith(
retryAttempt: 3,
usedFloodFallback: true,
),
_buildDirectMessage(
'm5',
).copyWith(retryAttempt: 3, usedFloodFallback: true),
contact: contact,
);
provider.markMessageFailed('m5');
provider.addSentMessage(
_buildDirectMessage('m6').copyWith(
retryAttempt: 3,
usedFloodFallback: true,
),
_buildDirectMessage(
'm6',
).copyWith(retryAttempt: 3, usedFloodFallback: true),
contact: contact,
);
provider.markMessageFailed('m6');
@@ -222,26 +277,21 @@ void main() {
};
provider.addSentMessage(
_buildDirectMessage('m7').copyWith(
retryAttempt: 3,
usedFloodFallback: true,
),
_buildDirectMessage(
'm7',
).copyWith(retryAttempt: 3, usedFloodFallback: true),
contact: contact,
);
provider.markMessageFailed('m7');
provider.addSentMessage(
_buildDirectMessage('m8'),
contact: contact,
);
provider.addSentMessage(_buildDirectMessage('m8'), contact: contact);
provider.markMessageSent('m8', 123, 10);
provider.markMessageDelivered(123, 150);
provider.addSentMessage(
_buildDirectMessage('m9').copyWith(
retryAttempt: 3,
usedFloodFallback: true,
),
_buildDirectMessage(
'm9',
).copyWith(retryAttempt: 3, usedFloodFallback: true),
contact: contact,
);
provider.markMessageFailed('m9');

View File

@@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/models/message_contact_location.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:meshcore_sar_app/utils/image_message_parser.dart';
import 'package:meshcore_sar_app/utils/voice_message_parser.dart';
import 'package:latlong2/latlong.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -15,15 +16,13 @@ void main() {
SharedPreferences.setMockInitialValues({});
});
test('marks VE2 envelope messages as voice', () {
test('marks VE3 envelope messages as voice', () {
final provider = MessagesProvider();
final envelope = VoiceEnvelope(
sessionId: 'deafbead',
mode: VoicePacketMode.mode1200,
total: 3,
durationMs: 2400,
senderKey6: 'aabbccddeeff',
timestampSec: 1700000000,
);
final message = Message(
@@ -45,7 +44,7 @@ void main() {
expect(stored.voiceId, equals('deafbead'));
});
test('marks legacy V text packets as voice', () {
test('does not mark legacy V text packets as voice', () {
final provider = MessagesProvider();
final packet = VoicePacket(
sessionId: '00112233',
@@ -69,8 +68,8 @@ void main() {
provider.addMessage(message);
final stored = provider.messages.single;
expect(stored.isVoice, isTrue);
expect(stored.voiceId, equals('00112233'));
expect(stored.isVoice, isFalse);
expect(stored.voiceId, isNull);
});
test('persists received contact location snapshots', () async {
@@ -106,5 +105,90 @@ void main() {
expect(snapshot.location.latitude, closeTo(46.0569, 0.000001));
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
});
test('tracks and persists media transfer counts and downloaders', () async {
final provider = MessagesProvider();
final voiceEnvelope = VoiceEnvelope(
sessionId: 'deafbead',
mode: VoicePacketMode.mode1200,
total: 3,
durationMs: 2400,
);
const imageEnvelope = ImageEnvelope(
sessionId: '01020304',
format: ImageFormat.avif,
total: 2,
width: 64,
height: 64,
sizeBytes: 2048,
);
provider.addMessage(
Message(
id: 'voice1',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000010,
text: voiceEnvelope.encodeText(),
receivedAt: DateTime.now(),
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
),
);
provider.addMessage(
Message(
id: 'image1',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000011,
text: imageEnvelope.encode(),
receivedAt: DateTime.now(),
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
),
);
provider.recordMediaTransfer(
sessionId: 'deafbead',
mediaType: 'voice',
requesterKey6: '112233445566',
requesterName: 'Alice',
);
provider.recordMediaTransfer(
sessionId: 'deafbead',
mediaType: 'voice',
requesterKey6: '112233445566',
requesterName: 'Alice',
);
provider.recordMediaTransfer(
sessionId: '01020304',
mediaType: 'image',
requesterKey6: 'a1b2c3d4e5f6',
requesterName: 'Bob',
);
await Future<void>.delayed(const Duration(milliseconds: 50));
final voiceDetails = provider.getMessageTransferDetails('voice1');
final imageDetails = provider.getMessageTransferDetails('image1');
expect(voiceDetails, isNotNull);
expect(voiceDetails!.totalTransfers, equals(2));
expect(voiceDetails.downloaders.single.requesterName, equals('Alice'));
expect(voiceDetails.downloaders.single.transferCount, equals(2));
expect(provider.transferCountForSession(voiceSessionId: 'deafbead'), 2);
expect(imageDetails?.totalTransfers, equals(1));
expect(provider.transferCountForSession(imageSessionId: '01020304'), 1);
final restoredProvider = MessagesProvider();
await restoredProvider.initialize();
final restoredVoice = restoredProvider.getMessageTransferDetails(
'voice1',
);
final restoredImage = restoredProvider.getMessageTransferDetails(
'image1',
);
expect(restoredVoice?.totalTransfers, equals(2));
expect(restoredVoice?.downloaders.single.requesterKey6, '112233445566');
expect(restoredImage?.downloaders.single.requesterName, equals('Bob'));
});
});
}