2026-03-20 01:54:31 -07:00
|
|
|
|
import 'dart:convert';
|
|
|
|
|
|
import 'dart:typed_data';
|
|
|
|
|
|
|
|
|
|
|
|
import 'package:crypto/crypto.dart';
|
|
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
|
|
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
|
|
|
|
|
import 'package:meshcore_open/models/contact.dart';
|
|
|
|
|
|
import 'package:meshcore_open/models/message.dart';
|
|
|
|
|
|
import 'package:meshcore_open/services/message_retry_service.dart';
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Helpers
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/// Replicates the SHA-256 computation from [MessageRetryService.computeExpectedAckHash]
|
|
|
|
|
|
/// so tests can cross-check without calling the real implementation twice.
|
2026-03-23 10:14:30 -07:00
|
|
|
|
int _manualAckHash(
|
2026-03-20 01:54:31 -07:00
|
|
|
|
int timestampSeconds,
|
|
|
|
|
|
int attemptMasked, // already masked to 0x03
|
|
|
|
|
|
String text,
|
|
|
|
|
|
Uint8List senderPubKey,
|
|
|
|
|
|
) {
|
|
|
|
|
|
final textBytes = utf8.encode(text);
|
|
|
|
|
|
final buffer = Uint8List(4 + 1 + textBytes.length + senderPubKey.length);
|
|
|
|
|
|
int offset = 0;
|
|
|
|
|
|
|
|
|
|
|
|
buffer[offset++] = timestampSeconds & 0xFF;
|
|
|
|
|
|
buffer[offset++] = (timestampSeconds >> 8) & 0xFF;
|
|
|
|
|
|
buffer[offset++] = (timestampSeconds >> 16) & 0xFF;
|
|
|
|
|
|
buffer[offset++] = (timestampSeconds >> 24) & 0xFF;
|
|
|
|
|
|
buffer[offset++] = attemptMasked & 0xFF;
|
|
|
|
|
|
|
|
|
|
|
|
buffer.setRange(offset, offset + textBytes.length, textBytes);
|
|
|
|
|
|
offset += textBytes.length;
|
|
|
|
|
|
buffer.setRange(offset, offset + senderPubKey.length, senderPubKey);
|
|
|
|
|
|
|
|
|
|
|
|
final hash = sha256.convert(buffer);
|
2026-03-23 10:14:30 -07:00
|
|
|
|
final bytes = Uint8List.fromList(hash.bytes.sublist(0, 4));
|
|
|
|
|
|
return (bytes[3] << 24) | (bytes[2] << 16) | (bytes[1] << 8) | bytes[0];
|
2026-03-20 01:54:31 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Uint8List _makeKey(int seed) {
|
|
|
|
|
|
final key = Uint8List(32);
|
|
|
|
|
|
for (int i = 0; i < 32; i++) {
|
|
|
|
|
|
key[i] = (seed + i) & 0xFF;
|
|
|
|
|
|
}
|
|
|
|
|
|
return key;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Uint8List _makeRecipientKey() {
|
|
|
|
|
|
final key = Uint8List(32);
|
|
|
|
|
|
for (int i = 0; i < 32; i++) {
|
|
|
|
|
|
key[i] = (0xAA + i) & 0xFF;
|
|
|
|
|
|
}
|
|
|
|
|
|
return key;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Contact _makeContact({
|
|
|
|
|
|
required Uint8List publicKey,
|
|
|
|
|
|
int pathLength = -1,
|
|
|
|
|
|
List<int> path = const [],
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return Contact(
|
|
|
|
|
|
publicKey: publicKey,
|
|
|
|
|
|
name: 'Test',
|
|
|
|
|
|
type: 1,
|
|
|
|
|
|
pathLength: pathLength,
|
|
|
|
|
|
path: Uint8List.fromList(path),
|
|
|
|
|
|
lastSeen: DateTime.now(),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Tests
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
|
// Fixed inputs reused across groups
|
|
|
|
|
|
const int fixedTs = 1700000000;
|
|
|
|
|
|
const String fixedText = 'Hello mesh';
|
|
|
|
|
|
final Uint8List fixedKey = _makeKey(0x11);
|
|
|
|
|
|
final Uint8List recipientKey = _makeRecipientKey();
|
|
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
group('computeExpectedAckHash — attempt masking', () {
|
|
|
|
|
|
test('attempts 0–3 all produce different hashes', () {
|
|
|
|
|
|
final hashes = List.generate(
|
|
|
|
|
|
4,
|
|
|
|
|
|
(i) => MessageRetryService.computeExpectedAckHash(
|
|
|
|
|
|
fixedTs,
|
|
|
|
|
|
i,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
fixedKey,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// All four must be pairwise distinct
|
|
|
|
|
|
for (int i = 0; i < hashes.length; i++) {
|
|
|
|
|
|
for (int j = i + 1; j < hashes.length; j++) {
|
|
|
|
|
|
expect(
|
|
|
|
|
|
hashes[i],
|
|
|
|
|
|
isNot(equals(hashes[j])),
|
|
|
|
|
|
reason: 'attempt $i and attempt $j should produce different hashes',
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('attempt 4 produces same hash as attempt 0 (4 & 0x03 == 0)', () {
|
|
|
|
|
|
final hash0 = MessageRetryService.computeExpectedAckHash(
|
|
|
|
|
|
fixedTs,
|
|
|
|
|
|
0,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
fixedKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
final hash4 = MessageRetryService.computeExpectedAckHash(
|
|
|
|
|
|
fixedTs,
|
|
|
|
|
|
4,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
fixedKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(hash4, equals(hash0));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('attempt 5 produces same hash as attempt 1 (5 & 0x03 == 1)', () {
|
|
|
|
|
|
final hash1 = MessageRetryService.computeExpectedAckHash(
|
|
|
|
|
|
fixedTs,
|
|
|
|
|
|
1,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
fixedKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
final hash5 = MessageRetryService.computeExpectedAckHash(
|
|
|
|
|
|
fixedTs,
|
|
|
|
|
|
5,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
fixedKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(hash5, equals(hash1));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('attempt 7 produces same hash as attempt 3 (7 & 0x03 == 3)', () {
|
|
|
|
|
|
final hash3 = MessageRetryService.computeExpectedAckHash(
|
|
|
|
|
|
fixedTs,
|
|
|
|
|
|
3,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
fixedKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
final hash7 = MessageRetryService.computeExpectedAckHash(
|
|
|
|
|
|
fixedTs,
|
|
|
|
|
|
7,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
fixedKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(hash7, equals(hash3));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('same inputs always produce the same hash (deterministic)', () {
|
|
|
|
|
|
final first = MessageRetryService.computeExpectedAckHash(
|
|
|
|
|
|
fixedTs,
|
|
|
|
|
|
2,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
fixedKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
final second = MessageRetryService.computeExpectedAckHash(
|
|
|
|
|
|
fixedTs,
|
|
|
|
|
|
2,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
fixedKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(first, equals(second));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('hash matches manual SHA-256 computation', () {
|
|
|
|
|
|
for (int attempt = 0; attempt < 4; attempt++) {
|
|
|
|
|
|
final actual = MessageRetryService.computeExpectedAckHash(
|
|
|
|
|
|
fixedTs,
|
|
|
|
|
|
attempt,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
fixedKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
final expected = _manualAckHash(fixedTs, attempt, fixedText, fixedKey);
|
|
|
|
|
|
expect(
|
|
|
|
|
|
actual,
|
|
|
|
|
|
equals(expected),
|
|
|
|
|
|
reason: 'mismatch at attempt $attempt',
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('different timestamps produce different hashes', () {
|
|
|
|
|
|
final hashA = MessageRetryService.computeExpectedAckHash(
|
|
|
|
|
|
1700000000,
|
|
|
|
|
|
0,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
fixedKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
final hashB = MessageRetryService.computeExpectedAckHash(
|
|
|
|
|
|
1700000001,
|
|
|
|
|
|
0,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
fixedKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(hashA, isNot(equals(hashB)));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('different texts produce different hashes', () {
|
|
|
|
|
|
final hashA = MessageRetryService.computeExpectedAckHash(
|
|
|
|
|
|
fixedTs,
|
|
|
|
|
|
0,
|
|
|
|
|
|
'Hello mesh',
|
|
|
|
|
|
fixedKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
final hashB = MessageRetryService.computeExpectedAckHash(
|
|
|
|
|
|
fixedTs,
|
|
|
|
|
|
0,
|
|
|
|
|
|
'Hello mesh!',
|
|
|
|
|
|
fixedKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(hashA, isNot(equals(hashB)));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('different sender keys produce different hashes', () {
|
|
|
|
|
|
final keyA = _makeKey(0x01);
|
|
|
|
|
|
final keyB = _makeKey(0x02);
|
|
|
|
|
|
final hashA = MessageRetryService.computeExpectedAckHash(
|
|
|
|
|
|
fixedTs,
|
|
|
|
|
|
0,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
keyA,
|
|
|
|
|
|
);
|
|
|
|
|
|
final hashB = MessageRetryService.computeExpectedAckHash(
|
|
|
|
|
|
fixedTs,
|
|
|
|
|
|
0,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
keyB,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(hashA, isNot(equals(hashB)));
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
group('buildSendTextMsgFrame — attempt encoding', () {
|
|
|
|
|
|
// Frame layout: [cmd(1)][txtType(1)][attempt(1)][timestamp(4)][pubKeyPrefix(6)][text][null(1)]
|
|
|
|
|
|
// So byte index 2 carries the raw attempt & 0xFF.
|
|
|
|
|
|
|
|
|
|
|
|
test('attempt 0 → byte[2] is 0', () {
|
|
|
|
|
|
final frame = buildSendTextMsgFrame(
|
|
|
|
|
|
recipientKey,
|
|
|
|
|
|
'hi',
|
|
|
|
|
|
attempt: 0,
|
|
|
|
|
|
timestampSeconds: fixedTs,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(frame[2], equals(0));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('attempt 3 → byte[2] is 3', () {
|
|
|
|
|
|
final frame = buildSendTextMsgFrame(
|
|
|
|
|
|
recipientKey,
|
|
|
|
|
|
'hi',
|
|
|
|
|
|
attempt: 3,
|
|
|
|
|
|
timestampSeconds: fixedTs,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(frame[2], equals(3));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('attempt 4 → byte[2] is 4 (raw value, not clamped to 3)', () {
|
|
|
|
|
|
final frame = buildSendTextMsgFrame(
|
|
|
|
|
|
recipientKey,
|
|
|
|
|
|
'hi',
|
|
|
|
|
|
attempt: 4,
|
|
|
|
|
|
timestampSeconds: fixedTs,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(frame[2], equals(4));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('attempt 255 → byte[2] is 255', () {
|
|
|
|
|
|
final frame = buildSendTextMsgFrame(
|
|
|
|
|
|
recipientKey,
|
|
|
|
|
|
'hi',
|
|
|
|
|
|
attempt: 255,
|
|
|
|
|
|
timestampSeconds: fixedTs,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(frame[2], equals(255));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('attempt 256 → byte[2] is 255 (clamped, not wrapped)', () {
|
|
|
|
|
|
final frame = buildSendTextMsgFrame(
|
|
|
|
|
|
recipientKey,
|
|
|
|
|
|
'hi',
|
|
|
|
|
|
attempt: 256,
|
|
|
|
|
|
timestampSeconds: fixedTs,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(frame[2], equals(255));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('byte[0] is cmdSendTxtMsg (2)', () {
|
|
|
|
|
|
final frame = buildSendTextMsgFrame(
|
|
|
|
|
|
recipientKey,
|
|
|
|
|
|
'hi',
|
|
|
|
|
|
attempt: 0,
|
|
|
|
|
|
timestampSeconds: fixedTs,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(frame[0], equals(cmdSendTxtMsg));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('byte[1] is txtTypePlain (0)', () {
|
|
|
|
|
|
final frame = buildSendTextMsgFrame(
|
|
|
|
|
|
recipientKey,
|
|
|
|
|
|
'hi',
|
|
|
|
|
|
attempt: 0,
|
|
|
|
|
|
timestampSeconds: fixedTs,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(frame[1], equals(txtTypePlain));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('timestamp bytes[3..6] are little-endian encoded', () {
|
|
|
|
|
|
final frame = buildSendTextMsgFrame(
|
|
|
|
|
|
recipientKey,
|
|
|
|
|
|
'hi',
|
|
|
|
|
|
attempt: 0,
|
|
|
|
|
|
timestampSeconds: fixedTs,
|
|
|
|
|
|
);
|
|
|
|
|
|
final decoded =
|
|
|
|
|
|
frame[3] | (frame[4] << 8) | (frame[5] << 16) | (frame[6] << 24);
|
|
|
|
|
|
expect(decoded, equals(fixedTs));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test(
|
|
|
|
|
|
'pub key prefix (bytes 7..12) matches first 6 bytes of recipient key',
|
|
|
|
|
|
() {
|
|
|
|
|
|
final frame = buildSendTextMsgFrame(
|
|
|
|
|
|
recipientKey,
|
|
|
|
|
|
'hi',
|
|
|
|
|
|
attempt: 0,
|
|
|
|
|
|
timestampSeconds: fixedTs,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(frame.sublist(7, 13), equals(recipientKey.sublist(0, 6)));
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
test('frame is null-terminated after text', () {
|
|
|
|
|
|
final frame = buildSendTextMsgFrame(
|
|
|
|
|
|
recipientKey,
|
|
|
|
|
|
'hi',
|
|
|
|
|
|
attempt: 0,
|
|
|
|
|
|
timestampSeconds: fixedTs,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(frame.last, equals(0));
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
group(
|
|
|
|
|
|
'ACK hash consistency between computeExpectedAckHash and firmware behavior',
|
|
|
|
|
|
() {
|
|
|
|
|
|
// The firmware reads the raw attempt byte from the frame, then masks it
|
|
|
|
|
|
// with & 3 when computing the ACK hash. Flutter does the same masking
|
|
|
|
|
|
// inside computeExpectedAckHash. So the two sides must agree.
|
|
|
|
|
|
|
|
|
|
|
|
test('attempt 4: flutter hash (4 & 3 = 0) equals hash for attempt 0', () {
|
|
|
|
|
|
// Flutter sends raw byte 4 in the frame, but computes hash with 4&3=0.
|
|
|
|
|
|
// Firmware reads 4, masks to 0, computes same hash → they match.
|
|
|
|
|
|
final hashFor4 = MessageRetryService.computeExpectedAckHash(
|
|
|
|
|
|
fixedTs,
|
|
|
|
|
|
4,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
fixedKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
final hashFor0 = MessageRetryService.computeExpectedAckHash(
|
|
|
|
|
|
fixedTs,
|
|
|
|
|
|
0,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
fixedKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(hashFor4, equals(hashFor0));
|
|
|
|
|
|
|
|
|
|
|
|
// Also confirm the frame byte is raw 4, not 0
|
|
|
|
|
|
final frame = buildSendTextMsgFrame(
|
|
|
|
|
|
recipientKey,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
attempt: 4,
|
|
|
|
|
|
timestampSeconds: fixedTs,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(frame[2], equals(4), reason: 'frame carries raw attempt byte');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test(
|
|
|
|
|
|
'attempt 3: flutter hash equals hash computed directly for attempt 3',
|
|
|
|
|
|
() {
|
|
|
|
|
|
// 3 & 3 == 3, so no wrapping — both sides agree.
|
|
|
|
|
|
final hashFor3 = MessageRetryService.computeExpectedAckHash(
|
|
|
|
|
|
fixedTs,
|
|
|
|
|
|
3,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
fixedKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
final hashFor3Direct = _manualAckHash(
|
|
|
|
|
|
fixedTs,
|
|
|
|
|
|
3,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
fixedKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(hashFor3, equals(hashFor3Direct));
|
|
|
|
|
|
|
|
|
|
|
|
final frame = buildSendTextMsgFrame(
|
|
|
|
|
|
recipientKey,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
attempt: 3,
|
|
|
|
|
|
timestampSeconds: fixedTs,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(frame[2], equals(3));
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
test(
|
|
|
|
|
|
'attempt 3 and attempt 4 produce DIFFERENT hashes (3&3=3 vs 4&3=0)',
|
|
|
|
|
|
() {
|
|
|
|
|
|
final hash3 = MessageRetryService.computeExpectedAckHash(
|
|
|
|
|
|
fixedTs,
|
|
|
|
|
|
3,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
fixedKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
final hash4 = MessageRetryService.computeExpectedAckHash(
|
|
|
|
|
|
fixedTs,
|
|
|
|
|
|
4,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
fixedKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(hash3, isNot(equals(hash4)));
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
test('attempt 8 (8&3=0) produces the same hash as attempt 0', () {
|
|
|
|
|
|
final hash8 = MessageRetryService.computeExpectedAckHash(
|
|
|
|
|
|
fixedTs,
|
|
|
|
|
|
8,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
fixedKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
final hash0 = MessageRetryService.computeExpectedAckHash(
|
|
|
|
|
|
fixedTs,
|
|
|
|
|
|
0,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
fixedKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(hash8, equals(hash0));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test(
|
|
|
|
|
|
'hash cycle repeats every 4 attempts (modular arithmetic holds)',
|
|
|
|
|
|
() {
|
|
|
|
|
|
for (int base = 0; base < 4; base++) {
|
|
|
|
|
|
final hashBase = MessageRetryService.computeExpectedAckHash(
|
|
|
|
|
|
fixedTs,
|
|
|
|
|
|
base,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
fixedKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
final hashPlus4 = MessageRetryService.computeExpectedAckHash(
|
|
|
|
|
|
fixedTs,
|
|
|
|
|
|
base + 4,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
fixedKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
final hashPlus8 = MessageRetryService.computeExpectedAckHash(
|
|
|
|
|
|
fixedTs,
|
|
|
|
|
|
base + 8,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
fixedKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(
|
|
|
|
|
|
hashPlus4,
|
|
|
|
|
|
equals(hashBase),
|
|
|
|
|
|
reason: 'attempt ${base + 4} should match attempt $base',
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(
|
|
|
|
|
|
hashPlus8,
|
|
|
|
|
|
equals(hashBase),
|
|
|
|
|
|
reason: 'attempt ${base + 8} should match attempt $base',
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
group('_AckHashMapping.attemptIndex — indirect verification via public API', () {
|
|
|
|
|
|
// _AckHashMapping is private; we validate its purpose indirectly: that
|
|
|
|
|
|
// computeExpectedAckHash records the correct per-attempt hash so that the
|
|
|
|
|
|
// right hash is matched when an ACK arrives.
|
|
|
|
|
|
|
|
|
|
|
|
test('each attempt index 0–3 produces a distinct 4-byte hash', () {
|
|
|
|
|
|
final hashes = <String, int>{};
|
|
|
|
|
|
for (int attempt = 0; attempt < 4; attempt++) {
|
|
|
|
|
|
final hash = MessageRetryService.computeExpectedAckHash(
|
|
|
|
|
|
fixedTs,
|
|
|
|
|
|
attempt,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
fixedKey,
|
|
|
|
|
|
);
|
2026-03-22 10:50:11 -07:00
|
|
|
|
final hex = hash.toRadixString(16).padLeft(8, '0');
|
2026-03-20 01:54:31 -07:00
|
|
|
|
expect(
|
|
|
|
|
|
hashes.containsKey(hex),
|
|
|
|
|
|
isFalse,
|
|
|
|
|
|
reason: 'attempt $attempt collides with attempt ${hashes[hex]}',
|
|
|
|
|
|
);
|
|
|
|
|
|
hashes[hex] = attempt;
|
|
|
|
|
|
}
|
|
|
|
|
|
expect(hashes.length, equals(4));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test(
|
|
|
|
|
|
'attempt index wraps: hash for attempt 4 matches stored hash for attempt 0',
|
|
|
|
|
|
() {
|
|
|
|
|
|
final storedHash = MessageRetryService.computeExpectedAckHash(
|
|
|
|
|
|
fixedTs,
|
|
|
|
|
|
0,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
fixedKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
// Simulates firmware reading raw attempt=4 and masking to 0 for hash.
|
|
|
|
|
|
final firmwareComputedHash = _manualAckHash(
|
|
|
|
|
|
fixedTs,
|
|
|
|
|
|
4 & 0x03, // firmware masks here
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
fixedKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(firmwareComputedHash, equals(storedHash));
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
test(
|
|
|
|
|
|
'attempt index 1 and 5 map to the same slot — ACK from either retry is matched',
|
|
|
|
|
|
() {
|
|
|
|
|
|
final hashForAttempt1 = MessageRetryService.computeExpectedAckHash(
|
|
|
|
|
|
fixedTs,
|
|
|
|
|
|
1,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
fixedKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
final hashForAttempt5 = MessageRetryService.computeExpectedAckHash(
|
|
|
|
|
|
fixedTs,
|
|
|
|
|
|
5,
|
|
|
|
|
|
fixedText,
|
|
|
|
|
|
fixedKey,
|
|
|
|
|
|
);
|
|
|
|
|
|
// Both should produce the identical bytes, confirming the service
|
|
|
|
|
|
// would record and match the correct attempt index.
|
|
|
|
|
|
expect(hashForAttempt5, equals(hashForAttempt1));
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
group('sendMessageWithRetry — auto path fallback', () {
|
|
|
|
|
|
test(
|
|
|
|
|
|
'preserves the contact path when auto-selection returns null',
|
|
|
|
|
|
() async {
|
|
|
|
|
|
final retryService = MessageRetryService();
|
|
|
|
|
|
Message? addedMessage;
|
|
|
|
|
|
final contact = _makeContact(
|
|
|
|
|
|
publicKey: recipientKey,
|
|
|
|
|
|
pathLength: 2,
|
|
|
|
|
|
path: const [0x10, 0x20],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
retryService.initialize(
|
|
|
|
|
|
RetryServiceConfig(
|
|
|
|
|
|
sendMessage: (_, _, _, _) {},
|
|
|
|
|
|
addMessage: (_, message) => addedMessage = message,
|
|
|
|
|
|
updateMessage: (_) {},
|
|
|
|
|
|
clearContactPath: (_) {},
|
|
|
|
|
|
setContactPath: (_, _, _) {},
|
|
|
|
|
|
selectRetryPath: (_, _, _, _) => null,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
await retryService.sendMessageWithRetry(
|
|
|
|
|
|
contact: contact,
|
|
|
|
|
|
text: 'hello',
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
expect(addedMessage, isNotNull);
|
|
|
|
|
|
expect(addedMessage!.pathLength, equals(2));
|
|
|
|
|
|
expect(
|
|
|
|
|
|
addedMessage!.pathBytes,
|
|
|
|
|
|
equals(Uint8List.fromList([0x10, 0x20])),
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
test('uses flood when contact is in flood mode', () async {
|
|
|
|
|
|
final retryService = MessageRetryService();
|
|
|
|
|
|
Message? addedMessage;
|
|
|
|
|
|
final contact = _makeContact(
|
|
|
|
|
|
publicKey: recipientKey,
|
|
|
|
|
|
pathLength: -1,
|
|
|
|
|
|
path: const [],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
retryService.initialize(
|
|
|
|
|
|
RetryServiceConfig(
|
|
|
|
|
|
sendMessage: (_, _, _, _) {},
|
|
|
|
|
|
addMessage: (_, message) => addedMessage = message,
|
|
|
|
|
|
updateMessage: (_) {},
|
|
|
|
|
|
clearContactPath: (_) {},
|
|
|
|
|
|
setContactPath: (_, _, _) {},
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
await retryService.sendMessageWithRetry(contact: contact, text: 'hello');
|
|
|
|
|
|
|
|
|
|
|
|
expect(addedMessage, isNotNull);
|
|
|
|
|
|
expect(addedMessage!.pathLength, equals(-1));
|
|
|
|
|
|
expect(addedMessage!.pathBytes, isEmpty);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|