Merge branch 'main' into fix/linux-ble-pairing-flow

This commit is contained in:
just_stuff_tm
2026-03-24 02:24:28 -04:00
committed by GitHub
80 changed files with 23673 additions and 1555 deletions
+8
View File
@@ -64,6 +64,10 @@ class AppSettingsService extends ChangeNotifier {
await updateSettings(_settings.copyWith(mapShowOtherNodes: value));
}
Future<void> setMapShowOverlaps(bool value) async {
await updateSettings(_settings.copyWith(mapShowOverlaps: value));
}
Future<void> setMapTimeFilterHours(double value) async {
await updateSettings(_settings.copyWith(mapTimeFilterHours: value));
}
@@ -214,4 +218,8 @@ class AppSettingsService extends ChangeNotifier {
Future<void> setTcpServerPort(int value) async {
await updateSettings(_settings.copyWith(tcpServerPort: value));
}
Future<void> setJumpToOldestUnread(bool value) async {
await updateSettings(_settings.copyWith(jumpToOldestUnread: value));
}
}
+34 -32
View File
@@ -11,7 +11,7 @@ import 'app_debug_log_service.dart';
class _AckHistoryEntry {
final String messageId;
final List<Uint8List> ackHashes;
final List<int> ackHashes;
final DateTime timestamp;
_AckHistoryEntry({
@@ -21,11 +21,16 @@ class _AckHistoryEntry {
});
}
/// (messageId, timestamp, attemptIndex) — stored per ACK hash for O(1) lookup.
/// (messageId, timestamp, attemptIndex, pathSelection) — stored per ACK hash
/// for O(1) lookup. [pathSelection] snapshots the route used for this
/// specific attempt so that a late PUSH_CODE_SEND_CONFIRMED credits the
/// correct path even when the message has since been retried on a different
/// route.
typedef AckHashMapping = ({
String messageId,
DateTime timestamp,
int attemptIndex,
PathSelection? pathSelection,
});
class RetryServiceConfig {
@@ -77,7 +82,7 @@ class MessageRetryService extends ChangeNotifier {
final Map<String, Contact> _pendingContacts = {};
final Map<String, List<PathSelection>> _attemptPathHistory = {};
final Map<String, AckHashMapping> _ackHashToMessageId = {};
final Map<String, List<Uint8List>> _expectedAckHashes = {};
final Map<String, List<int>> _expectedAckHashes = {};
final List<_AckHistoryEntry> _ackHistory = [];
final Map<String, List<String>> _sendQueue = {};
final Set<String> _activeMessages = {};
@@ -98,7 +103,7 @@ class MessageRetryService extends ChangeNotifier {
/// Compute expected ACK hash using same algorithm as firmware:
/// SHA256([timestamp(4)][attempt(1)][text][sender_pubkey(32)]) -> first 4 bytes
static Uint8List computeExpectedAckHash(
static int computeExpectedAckHash(
int timestampSeconds,
int attempt,
String text,
@@ -126,7 +131,8 @@ class MessageRetryService extends ChangeNotifier {
// Compute SHA256 and return first 4 bytes
final hash = sha256.convert(buffer);
return Uint8List.fromList(hash.bytes.sublist(0, 4));
final bytes = Uint8List.fromList(hash.bytes.sublist(0, 4));
return (bytes[3] << 24) | (bytes[2] << 16) | (bytes[1] << 8) | bytes[0];
}
Future<void> sendMessageWithRetry({
@@ -324,9 +330,7 @@ class MessageRetryService extends ChangeNotifier {
outboundText,
selfPubKey,
);
final expectedHashHex = expectedHash
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final expectedHashHex = expectedHash.toRadixString(16).padLeft(8, '0');
_expectedHashToMessageId[expectedHashHex] = messageId;
final shortText = message.text.length > 20
@@ -341,13 +345,11 @@ class MessageRetryService extends ChangeNotifier {
config.sendMessage(contact, message.text, attempt, timestampSeconds);
}
bool updateMessageFromSent(Uint8List ackHash, int timeoutMs) {
bool updateMessageFromSent(int ackHash, int timeoutMs) {
final config = _config;
if (config == null) return false;
final ackHashHex = ackHash
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final ackHashHex = ackHash.toRadixString(16).padLeft(8, '0');
// Try hash-based matching (fixes LoRa message drops causing mismatches)
String? messageId = _expectedHashToMessageId.remove(ackHashHex);
@@ -385,14 +387,13 @@ class MessageRetryService extends ChangeNotifier {
messageId: messageId,
timestamp: DateTime.now(),
attemptIndex: message.retryCount,
pathSelection: _selectionFromMessage(message),
);
// Add this ACK hash to the list of expected ACKs for this message (for history)
_expectedAckHashes[messageId] ??= [];
if (!_expectedAckHashes[messageId]!.any(
(hash) => listEquals(hash, ackHash),
)) {
_expectedAckHashes[messageId]!.add(Uint8List.fromList(ackHash));
if (!_expectedAckHashes[messageId]!.any((hash) => hash == ackHash)) {
_expectedAckHashes[messageId]!.add(ackHash);
}
// Calculate timeout: prefer ML prediction, then device-provided, then physics fallback
@@ -400,14 +401,11 @@ class MessageRetryService extends ChangeNotifier {
int actualTimeout = timeoutMs;
if (config.calculateTimeout != null) {
final calculated = config.calculateTimeout!(
actualTimeout = config.calculateTimeout!(
pathLengthValue,
message.text.length,
contactKey: contact.publicKeyHex,
);
if (timeoutMs <= 0 || calculated < timeoutMs) {
actualTimeout = calculated;
}
}
final updatedMessage = message.copyWith(
@@ -559,10 +557,10 @@ class MessageRetryService extends ChangeNotifier {
}
}
bool _checkAckHistory(Uint8List ackHash) {
bool _checkAckHistory(int ackHash) {
for (final entry in _ackHistory) {
for (final expectedHash in entry.ackHashes) {
if (listEquals(expectedHash, ackHash)) {
if (expectedHash == ackHash) {
return true;
}
}
@@ -570,13 +568,12 @@ class MessageRetryService extends ChangeNotifier {
return false;
}
void handleAckReceived(Uint8List ackHash, int tripTimeMs) {
void handleAckReceived(int ackHash, int tripTimeMs) {
final config = _config;
String? matchedMessageId;
int? matchedAttemptIndex;
final ackHashHex = ackHash
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
PathSelection? matchedPathSelection;
final ackHashHex = ackHash.toRadixString(16).padLeft(8, '0');
// Clean up old ACK hash mappings (older than 15 minutes)
final cutoffTime = DateTime.now().subtract(const Duration(minutes: 15));
@@ -595,6 +592,7 @@ class MessageRetryService extends ChangeNotifier {
if (mapping != null) {
matchedMessageId = mapping.messageId;
matchedAttemptIndex = mapping.attemptIndex;
matchedPathSelection = mapping.pathSelection;
} else {
config?.debugLogService?.warn(
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex not found in direct mapping, trying fallback',
@@ -606,7 +604,7 @@ class MessageRetryService extends ChangeNotifier {
final expectedHashes = entry.value;
for (final expectedHash in expectedHashes) {
if (listEquals(expectedHash, ackHash)) {
if (expectedHash == ackHash) {
matchedMessageId = messageId;
matchedAttemptIndex = expectedHashes.indexOf(expectedHash);
break;
@@ -625,13 +623,13 @@ class MessageRetryService extends ChangeNotifier {
}
final contact = _pendingContacts[matchedMessageId];
final ackedAttempt = matchedAttemptIndex ?? message.retryCount;
final selection = _selectionFromMessage(message);
final selection = matchedPathSelection ?? _selectionFromMessage(message);
final shortText = message.text.length > 20
? '${message.text.substring(0, 20)}...'
: message.text;
config?.debugLogService?.info(
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex ✓ "$shortText" delivered to ${contact?.name ?? "unknown"} on retry ${ackedAttempt + 1} in ${tripTimeMs}ms',
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex ✓ "$shortText" delivered to ${contact?.name ?? "unknown"} on attempt $ackedAttempt in ${tripTimeMs}ms',
tag: 'AckHash',
);
@@ -643,6 +641,8 @@ class MessageRetryService extends ChangeNotifier {
tripTimeMs: tripTimeMs,
);
final wasAlreadyResolved = _resolvedMessages.contains(matchedMessageId);
_cleanupMessage(matchedMessageId);
config?.updateMessage(deliveredMessage);
@@ -665,7 +665,9 @@ class MessageRetryService extends ChangeNotifier {
tripTimeMs,
);
}
_onMessageResolved(matchedMessageId, contact.publicKeyHex);
if (!wasAlreadyResolved) {
_onMessageResolved(matchedMessageId, contact.publicKeyHex);
}
}
notifyListeners();
@@ -685,11 +687,11 @@ class MessageRetryService extends ChangeNotifier {
}
}
String? getContactKeyForAckHash(Uint8List ackHash) {
String? getContactKeyForAckHash(int ackHash) {
for (var entry in _pendingMessages.entries) {
final message = entry.value;
if (message.expectedAckHash != null &&
listEquals(message.expectedAckHash, ackHash)) {
message.expectedAckHash == ackHash) {
final contact = _pendingContacts[entry.key];
return contact?.publicKeyHex;
}
+10
View File
@@ -565,6 +565,16 @@ class PathHistoryService extends ChangeNotifier {
_floodStats.remove(oldest);
}
}
void clearAllHistories() {
_cache.clear();
_cacheAccessOrder.clear();
_autoRotationIndex.clear();
_floodStats.clear();
_storage.clearAllPathHistories();
_version = 0;
notifyListeners();
}
}
class _DeferredPathRecord {
+11 -11
View File
@@ -273,7 +273,7 @@ class UsbSerialService {
throw StateError('USB serial port is not open');
}
final packet = wrapUsbSerialTxFrame(data);
_logFrameSummary('USB TX frame', data);
// _logFrameSummary('USB TX frame', data);
if (_useAndroidUsbHost) {
try {
await _androidMethodChannel.invokeMethod<void>('write', {
@@ -447,16 +447,16 @@ class UsbSerialService {
await _frameController.close();
}
void _logFrameSummary(String prefix, Uint8List bytes) {
if (bytes.isEmpty) {
_debugLogService?.info('$prefix len=0', tag: 'USB Serial');
return;
}
_debugLogService?.info(
'$prefix code=${bytes[0]} len=${bytes.length}',
tag: 'USB Serial',
);
}
// void _logFrameSummary(String prefix, Uint8List bytes) {
// if (bytes.isEmpty) {
// _debugLogService?.info('$prefix len=0', tag: 'USB Serial');
// return;
// }
// _debugLogService?.info(
// '$prefix code=${bytes[0]} len=${bytes.length}',
// tag: 'USB Serial',
// );
// }
/// Returns an ordered list of port paths to try for [portName].
///