mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
Msg Retry fixes, channel message fixes. Notification fixes. Make more desktop friendly. Enhance retry algo. Fix predicted location clustering add retries to reactions and fix the reactions in private DMS centralize and cleanup code in var areas
This commit is contained in:
parent
53caec3e14
commit
4962a48e64
61 changed files with 4509 additions and 900 deletions
|
|
@ -16,7 +16,7 @@ if (keystorePropertiesFile.exists()) {
|
|||
android {
|
||||
namespace = "com.meshcore.meshcore_open"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
ndkVersion = "29.0.14206865"
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
|
|
|
|||
|
|
@ -171,6 +171,11 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
// Intentionally global (not per-contact): tracks overall network activity.
|
||||
// Frequent RX from any source indicates a busy network with more collisions.
|
||||
DateTime _lastRxTime = DateTime.now();
|
||||
DateTime _lastRadioRxTime = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
DateTime _lastContactMsgRxTime = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
static const int _radioQuietMs = 3000;
|
||||
static const int _radioQuietMaxWaitMs = 3000;
|
||||
static const int _contactMsgBackoffMs = 5000;
|
||||
bool _batteryRequested = false;
|
||||
bool _awaitingSelfInfo = false;
|
||||
bool _hasReceivedDeviceInfo = false;
|
||||
|
|
@ -694,24 +699,32 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
_loadChannelOrder();
|
||||
|
||||
// Initialize retry service callbacks
|
||||
_retryService?.initialize(
|
||||
sendMessageCallback: _sendMessageDirect,
|
||||
addMessageCallback: _addMessage,
|
||||
updateMessageCallback: _updateMessage,
|
||||
clearContactPathCallback: clearContactPath,
|
||||
setContactPathCallback: setContactPath,
|
||||
calculateTimeoutCallback:
|
||||
_retryService?.initialize(RetryServiceConfig(
|
||||
sendMessage: _sendMessageDirect,
|
||||
addMessage: _addMessage,
|
||||
updateMessage: _updateMessage,
|
||||
clearContactPath: clearContactPath,
|
||||
setContactPath: setContactPath,
|
||||
calculateTimeout:
|
||||
(pathLength, messageBytes, {String? contactKey}) => calculateTimeout(
|
||||
pathLength: pathLength,
|
||||
messageBytes: messageBytes,
|
||||
contactKey: contactKey,
|
||||
),
|
||||
getSelfPublicKeyCallback: () => _selfPublicKey,
|
||||
prepareContactOutboundTextCallback: prepareContactOutboundText,
|
||||
getSelfPublicKey: () => _selfPublicKey,
|
||||
prepareContactOutboundText: prepareContactOutboundText,
|
||||
appSettingsService: appSettingsService,
|
||||
debugLogService: _appDebugLogService,
|
||||
recordPathResultCallback: _recordPathResult,
|
||||
onDeliveryObservedCallback:
|
||||
recordPathResult: _recordPathResult,
|
||||
selectRetryPath:
|
||||
(contactKey, attemptIndex, maxRetries, recentSelections) =>
|
||||
_selectAutoPathForAttempt(
|
||||
contactKey,
|
||||
attemptIndex: attemptIndex,
|
||||
maxRetries: maxRetries,
|
||||
recentSelections: recentSelections,
|
||||
),
|
||||
onDeliveryObserved:
|
||||
(contactKey, pathLength, messageBytes, tripTimeMs) {
|
||||
final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds;
|
||||
_timeoutPredictionService?.recordObservation(
|
||||
|
|
@ -722,7 +735,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
secondsSinceLastRx: secSinceRx,
|
||||
);
|
||||
},
|
||||
);
|
||||
));
|
||||
final maxRetries = _appSettingsService?.settings.maxMessageRetries ?? 5;
|
||||
_retryService?.setMaxRetries(maxRetries);
|
||||
}
|
||||
|
||||
Future<void> loadContactCache() async {
|
||||
|
|
@ -753,22 +768,61 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
void _sendMessageDirect(
|
||||
Future<void> _waitForRadioQuiet() async {
|
||||
// Wait for backoff after receiving a contact message (avoid collision
|
||||
// with their transmission still propagating through repeaters)
|
||||
final msSinceContactMsg = DateTime.now()
|
||||
.difference(_lastContactMsgRxTime)
|
||||
.inMilliseconds;
|
||||
if (msSinceContactMsg < _contactMsgBackoffMs) {
|
||||
final waitMs = _contactMsgBackoffMs - msSinceContactMsg;
|
||||
debugPrint('Contact message backoff: waiting ${waitMs}ms');
|
||||
await Future<void>.delayed(Duration(milliseconds: waitMs));
|
||||
}
|
||||
|
||||
// Then wait for radio silence (no RF activity for 3s)
|
||||
final msSinceRx = DateTime.now()
|
||||
.difference(_lastRadioRxTime)
|
||||
.inMilliseconds;
|
||||
if (msSinceRx >= _radioQuietMs) return;
|
||||
|
||||
final deadline = DateTime.now().add(
|
||||
const Duration(milliseconds: _radioQuietMaxWaitMs),
|
||||
);
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
final quiet = DateTime.now().difference(_lastRadioRxTime).inMilliseconds;
|
||||
if (quiet >= _radioQuietMs) {
|
||||
debugPrint('Radio quiet for ${quiet}ms, proceeding with send');
|
||||
return;
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
}
|
||||
debugPrint(
|
||||
'Radio quiet wait exceeded ${_radioQuietMaxWaitMs}ms, sending anyway',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _sendMessageDirect(
|
||||
Contact contact,
|
||||
String text,
|
||||
int attempt,
|
||||
int timestampSeconds,
|
||||
) async {
|
||||
if (!isConnected || text.isEmpty) return;
|
||||
final outboundText = prepareContactOutboundText(contact, text);
|
||||
await sendFrame(
|
||||
buildSendTextMsgFrame(
|
||||
contact.publicKey,
|
||||
outboundText,
|
||||
attempt: attempt,
|
||||
timestampSeconds: timestampSeconds,
|
||||
),
|
||||
);
|
||||
try {
|
||||
await _waitForRadioQuiet();
|
||||
final outboundText = prepareContactOutboundText(contact, text);
|
||||
await sendFrame(
|
||||
buildSendTextMsgFrame(
|
||||
contact.publicKey,
|
||||
outboundText,
|
||||
attempt: attempt,
|
||||
timestampSeconds: timestampSeconds,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.error('Failed to send message: $e', tag: 'Connector');
|
||||
}
|
||||
}
|
||||
|
||||
void _updateMessage(Message message) {
|
||||
|
|
@ -784,6 +838,20 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// If this is a reaction message, update the target message's reaction status
|
||||
final reactionInfo = ReactionHelper.parseReaction(message.text);
|
||||
if (reactionInfo != null &&
|
||||
(message.status == MessageStatus.delivered ||
|
||||
message.status == MessageStatus.failed)) {
|
||||
final contactKey2 = pubKeyToHex(message.senderKey);
|
||||
_setReactionStatus(contactKey2, reactionInfo, message.status);
|
||||
_messageStore.saveMessages(
|
||||
contactKey2,
|
||||
_conversations[contactKey2] ?? [],
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void _recordPathResult(
|
||||
|
|
@ -793,35 +861,68 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
int? tripTimeMs,
|
||||
) {
|
||||
if (_pathHistoryService == null) return;
|
||||
final settings = _appSettingsService?.settings;
|
||||
_pathHistoryService!.recordPathResult(
|
||||
contactPubKeyHex,
|
||||
selection,
|
||||
success: success,
|
||||
tripTimeMs: tripTimeMs,
|
||||
successIncrement: settings?.routeWeightSuccessIncrement ?? 0.2,
|
||||
failureDecrement: settings?.routeWeightFailureDecrement ?? 0.2,
|
||||
maxWeight: settings?.maxRouteWeight ?? 5.0,
|
||||
);
|
||||
|
||||
// Flood path attribution: when a flood delivery succeeds, credit the
|
||||
// contact's current device path so the route the ACK traveled back
|
||||
// through gets a weight boost in the path history.
|
||||
if (selection.useFlood && success) {
|
||||
final contact = _contacts.cast<Contact?>().firstWhere(
|
||||
(c) => c?.publicKeyHex == contactPubKeyHex,
|
||||
orElse: () => null,
|
||||
);
|
||||
if (contact != null &&
|
||||
contact.pathLength >= 0 &&
|
||||
contact.path.isNotEmpty) {
|
||||
_pathHistoryService!.recordFloodPathAttribution(
|
||||
contactPubKeyHex: contactPubKeyHex,
|
||||
pathBytes: contact.path,
|
||||
hopCount: contact.pathLength,
|
||||
tripTimeMs: tripTimeMs,
|
||||
successIncrement: settings?.routeWeightSuccessIncrement ?? 0.2,
|
||||
maxWeight: settings?.maxRouteWeight ?? 5.0,
|
||||
);
|
||||
}
|
||||
|
||||
// Request a fresh contact from the device so the next flood
|
||||
// attribution uses the most up-to-date path.
|
||||
if (contact != null) {
|
||||
unawaited(getContactByKey(contact.publicKey));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Contact _applyAutoSelection(Contact contact, PathSelection? selection) {
|
||||
if (selection == null ||
|
||||
selection.useFlood ||
|
||||
selection.pathBytes.isEmpty) {
|
||||
return contact;
|
||||
PathSelection? _selectAutoPathForAttempt(
|
||||
String contactPubKeyHex, {
|
||||
required int attemptIndex,
|
||||
required int maxRetries,
|
||||
List<PathSelection> recentSelections = const [],
|
||||
}) {
|
||||
final hasKnownPaths =
|
||||
_pathHistoryService?.getRecentPaths(contactPubKeyHex).isNotEmpty ?? false;
|
||||
if (!hasKnownPaths) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Contact(
|
||||
publicKey: contact.publicKey,
|
||||
name: contact.name,
|
||||
type: contact.type,
|
||||
flags: contact.flags,
|
||||
pathLength: selection.hopCount >= 0
|
||||
? selection.hopCount
|
||||
: contact.pathLength,
|
||||
path: Uint8List.fromList(selection.pathBytes),
|
||||
latitude: contact.latitude,
|
||||
longitude: contact.longitude,
|
||||
lastSeen: contact.lastSeen,
|
||||
lastMessageAt: contact.lastMessageAt,
|
||||
final selection = _pathHistoryService?.selectPathForAttempt(
|
||||
contactPubKeyHex,
|
||||
attemptIndex: attemptIndex,
|
||||
maxRetries: maxRetries,
|
||||
recentSelections: recentSelections,
|
||||
);
|
||||
if (selection != null) {
|
||||
_pathHistoryService?.recordPathAttempt(contactPubKeyHex, selection);
|
||||
}
|
||||
return selection;
|
||||
}
|
||||
|
||||
Future<void> startScan({
|
||||
|
|
@ -1730,47 +1831,43 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
Future<void> sendMessage(Contact contact, String text) async {
|
||||
if (!isConnected || text.isEmpty) return;
|
||||
|
||||
// Handle auto-rotation if enabled
|
||||
PathSelection? autoSelection;
|
||||
if (_appSettingsService?.settings.autoRouteRotationEnabled == true) {
|
||||
autoSelection = _pathHistoryService?.getNextAutoPathSelection(
|
||||
// Check if this is a reaction - apply locally with pending status and route through retry service
|
||||
final reactionInfo = ReactionHelper.parseReaction(text);
|
||||
if (reactionInfo != null) {
|
||||
_conversations.putIfAbsent(contact.publicKeyHex, () => []);
|
||||
final messages = _conversations[contact.publicKeyHex]!;
|
||||
|
||||
// Apply reaction locally with pending status
|
||||
_processOutgoingContactReaction(messages, reactionInfo, contact);
|
||||
_setReactionStatus(
|
||||
contact.publicKeyHex,
|
||||
reactionInfo,
|
||||
MessageStatus.pending,
|
||||
);
|
||||
if (autoSelection != null) {
|
||||
_pathHistoryService?.recordPathAttempt(
|
||||
contact.publicKeyHex,
|
||||
autoSelection,
|
||||
);
|
||||
if (!autoSelection.useFlood && autoSelection.pathBytes.isNotEmpty) {
|
||||
await setContactPath(
|
||||
contact,
|
||||
Uint8List.fromList(autoSelection.pathBytes),
|
||||
autoSelection.pathBytes.length,
|
||||
);
|
||||
}
|
||||
_messageStore.saveMessages(contact.publicKeyHex, messages);
|
||||
notifyListeners();
|
||||
|
||||
// Route through retry service (same as normal messages)
|
||||
// Don't use auto-rotation for reactions — just send directly
|
||||
if (_retryService != null) {
|
||||
_retryService!.sendMessageWithRetry(contact: contact, text: text);
|
||||
} else {
|
||||
final outboundText = prepareContactOutboundText(contact, text);
|
||||
await sendFrame(buildSendTextMsgFrame(contact.publicKey, outboundText));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (_retryService != null) {
|
||||
final pathBytes = _resolveOutgoingPathBytes(contact, autoSelection);
|
||||
final pathLength = _resolveOutgoingPathLength(contact, autoSelection);
|
||||
final selectedContact = _applyAutoSelection(contact, autoSelection);
|
||||
await _retryService!.sendMessageWithRetry(
|
||||
contact: selectedContact,
|
||||
text: text,
|
||||
pathSelection: autoSelection,
|
||||
pathBytes: pathBytes,
|
||||
pathLength: pathLength,
|
||||
);
|
||||
await _retryService!.sendMessageWithRetry(contact: contact, text: text);
|
||||
} else {
|
||||
// Fallback to old behavior if retry service not initialized
|
||||
final pathBytes = _resolveOutgoingPathBytes(contact, autoSelection);
|
||||
final pathLength = _resolveOutgoingPathLength(contact, autoSelection);
|
||||
final resolved = resolvePathSelection(contact);
|
||||
final message = Message.outgoing(
|
||||
contact.publicKey,
|
||||
text,
|
||||
pathLength: pathLength,
|
||||
pathBytes: pathBytes,
|
||||
pathLength: resolved.useFlood ? -1 : resolved.hopCount,
|
||||
pathBytes: Uint8List.fromList(resolved.pathBytes),
|
||||
);
|
||||
_addMessage(contact.publicKeyHex, message);
|
||||
notifyListeners();
|
||||
|
|
@ -1808,6 +1905,16 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
if (_activeTransport == MeshCoreTransportType.usb) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
final idx = _contacts.indexWhere(
|
||||
(c) => c.publicKeyHex == contact.publicKeyHex,
|
||||
);
|
||||
if (idx != -1) {
|
||||
_contacts[idx] = _contacts[idx].copyWith(
|
||||
pathLength: customPath.length,
|
||||
path: customPath,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
} finally {
|
||||
completer.complete();
|
||||
}
|
||||
|
|
@ -1924,6 +2031,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
await _contactStore.saveContacts(_contacts);
|
||||
appLogger.info('Saved contacts to storage', tag: 'Connector');
|
||||
|
||||
// Update any in-flight retries so they use the new path override
|
||||
_retryService?.updatePendingContact(_contacts[index]);
|
||||
|
||||
// If setting a specific path (not flood, not auto), also sync with device
|
||||
if (pathLen != null && pathLen >= 0 && pathBytes != null) {
|
||||
appLogger.info('Sending path to device...', tag: 'Connector');
|
||||
|
|
@ -1942,27 +2052,27 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
final autoRotationEnabled =
|
||||
_appSettingsService?.settings.autoRouteRotationEnabled == true;
|
||||
if (autoRotationEnabled && contact.pathOverride == null) {
|
||||
autoSelection = _pathHistoryService?.getNextAutoPathSelection(
|
||||
final maxRetries = _appSettingsService?.settings.maxMessageRetries ?? 5;
|
||||
autoSelection = _selectAutoPathForAttempt(
|
||||
contact.publicKeyHex,
|
||||
attemptIndex: 0,
|
||||
maxRetries: maxRetries,
|
||||
);
|
||||
if (autoSelection != null) {
|
||||
_pathHistoryService?.recordPathAttempt(
|
||||
contact.publicKeyHex,
|
||||
autoSelection,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final pathBytes = _resolveOutgoingPathBytes(contact, autoSelection);
|
||||
final pathLength = _resolveOutgoingPathLength(contact, autoSelection) ?? -1;
|
||||
final resolved = resolvePathSelection(contact, selection: autoSelection);
|
||||
|
||||
if (pathLength < 0) {
|
||||
if (resolved.useFlood) {
|
||||
await clearContactPath(contact);
|
||||
} else {
|
||||
await setContactPath(contact, pathBytes, pathLength);
|
||||
await setContactPath(
|
||||
contact,
|
||||
Uint8List.fromList(resolved.pathBytes),
|
||||
resolved.hopCount,
|
||||
);
|
||||
}
|
||||
|
||||
return _selectionFromPath(pathLength, pathBytes);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
void trackRepeaterAck({
|
||||
|
|
@ -2626,6 +2736,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
case pushCodeStatusResponse:
|
||||
break;
|
||||
case pushCodeLogRxData:
|
||||
_lastRadioRxTime = DateTime.now();
|
||||
_handleRxData(frame);
|
||||
_handleLogRxData(frame);
|
||||
break;
|
||||
|
|
@ -2929,16 +3040,17 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
/// Physics-based worst-case timeout (ceiling).
|
||||
int _physicsMaxTimeout(int pathLength, int airtime) {
|
||||
if (pathLength < 0) {
|
||||
// Match firmware: SEND_TIMEOUT_BASE_MILLIS + (FLOOD_SEND_TIMEOUT_FACTOR * airtime)
|
||||
return 500 + (16 * airtime);
|
||||
} else {
|
||||
return 500 + ((airtime * 6 + 250) * (pathLength + 1));
|
||||
}
|
||||
}
|
||||
|
||||
/// Physics-based minimum timeout (floor): raw traversal time.
|
||||
int _physicsMinTimeout(int pathLength, int airtime) {
|
||||
if (pathLength < 0) {
|
||||
return airtime;
|
||||
// Same as max for flood — firmware uses a single formula
|
||||
return 500 + (16 * airtime);
|
||||
} else {
|
||||
return airtime * (pathLength + 1);
|
||||
}
|
||||
|
|
@ -2955,7 +3067,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
final physicsMin = _physicsMinTimeout(pathLength, airtime);
|
||||
final physicsMax = _physicsMaxTimeout(pathLength, airtime);
|
||||
|
||||
// Try ML-based prediction, clamped between physics bounds
|
||||
// Try ML-based prediction
|
||||
final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds;
|
||||
final mlTimeout = _timeoutPredictionService?.predictTimeout(
|
||||
contactKey: contactKey,
|
||||
|
|
@ -2964,9 +3076,14 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
secondsSinceLastRx: secSinceRx,
|
||||
);
|
||||
if (mlTimeout != null) {
|
||||
if (pathLength < 0) {
|
||||
// Flood: trust ML, only enforce firmware formula as floor
|
||||
return mlTimeout.clamp(physicsMin, mlTimeout);
|
||||
}
|
||||
return mlTimeout.clamp(physicsMin, physicsMax);
|
||||
}
|
||||
|
||||
// No ML data — use firmware formula
|
||||
return physicsMax;
|
||||
}
|
||||
|
||||
|
|
@ -3255,6 +3372,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
}
|
||||
|
||||
if (message != null) {
|
||||
if (!message.isOutgoing) {
|
||||
_lastContactMsgRxTime = DateTime.now();
|
||||
}
|
||||
// Ignore messages from self (device hearing its own broadcast)
|
||||
// BUT allow repeated messages (pathLength indicates it went through repeater)
|
||||
if (_selfPublicKey != null &&
|
||||
|
|
@ -3302,7 +3422,6 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
_appSettingsService != null) {
|
||||
final settings = _appSettingsService!.settings;
|
||||
if (settings.notificationsEnabled && settings.notifyOnNewMessage) {
|
||||
// Find the contact name
|
||||
if (contact?.type == advTypeChat) {
|
||||
_notificationService.showMessageNotification(
|
||||
contactName: contact?.name ?? 'Unknown',
|
||||
|
|
@ -3313,7 +3432,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
} else if (contact?.type == advTypeRoom) {
|
||||
_notificationService.showMessageNotification(
|
||||
contactName: contact?.name ?? 'Unknown Room',
|
||||
message: message.text.substring(4),
|
||||
message: message.text.length > 4
|
||||
? message.text.substring(4)
|
||||
: message.text,
|
||||
contactId: message.senderKeyHex,
|
||||
badgeCount: getTotalUnreadCount(),
|
||||
);
|
||||
|
|
@ -3488,6 +3609,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
|
||||
_notificationService.showChannelMessageNotification(
|
||||
channelName: label,
|
||||
senderName: message.senderName,
|
||||
message: message.text,
|
||||
channelIndex: channelIndex,
|
||||
badgeCount: getTotalUnreadCount(),
|
||||
|
|
@ -3495,14 +3617,20 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
}
|
||||
|
||||
void _handleIncomingChannelMessage(Uint8List frame) {
|
||||
final message = ChannelMessage.fromFrame(frame);
|
||||
if (message != null && message.channelIndex != null) {
|
||||
final parsed = ChannelMessage.fromFrame(frame);
|
||||
if (parsed != null && parsed.channelIndex != null) {
|
||||
if (_shouldDropSelfChannelMessage(
|
||||
message.senderName,
|
||||
message.pathBytes,
|
||||
parsed.senderName,
|
||||
parsed.pathBytes,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
final contentHash = _computeContentHash(
|
||||
parsed.channelIndex!,
|
||||
parsed.timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
'${parsed.senderName}: ${parsed.text}',
|
||||
);
|
||||
final message = parsed.copyWith(packetHash: contentHash);
|
||||
_updateContactLastMessageAtByName(
|
||||
message.senderName,
|
||||
message.timestamp,
|
||||
|
|
@ -3554,6 +3682,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
return;
|
||||
}
|
||||
|
||||
final pktHash = _computePacketHash(packet.payloadType, packet.payload);
|
||||
|
||||
final message = ChannelMessage(
|
||||
senderKey: null,
|
||||
senderName: parsed.senderName,
|
||||
|
|
@ -3561,9 +3691,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
|
||||
isOutgoing: false,
|
||||
status: ChannelMessageStatus.sent,
|
||||
pathLength: packet.isFlood ? packet.pathBytes.length : 0,
|
||||
pathLength: packet.isFlood ? packet.hopCount : 0,
|
||||
pathBytes: packet.pathBytes,
|
||||
channelIndex: channel.index,
|
||||
packetHash: pktHash,
|
||||
);
|
||||
|
||||
_updateContactLastMessageAtByName(
|
||||
|
|
@ -3611,21 +3742,13 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
|
||||
final retryService = _retryService;
|
||||
if (retryService != null &&
|
||||
retryService.updateMessageFromSent(
|
||||
ackHash,
|
||||
timeoutMs,
|
||||
allowQueueFallback: false,
|
||||
)) {
|
||||
retryService.updateMessageFromSent(ackHash, timeoutMs)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_markNextPendingChannelMessageSent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (retryService != null) {
|
||||
retryService.updateMessageFromSent(ackHash, timeoutMs);
|
||||
}
|
||||
} else {
|
||||
// Fallback to old behavior
|
||||
for (var messages in _conversations.values) {
|
||||
|
|
@ -4016,55 +4139,98 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
ReactionInfo reactionInfo,
|
||||
String contactPubKeyHex,
|
||||
) {
|
||||
// Find target message by computing hash and comparing
|
||||
final targetHash = reactionInfo.targetHash;
|
||||
final contact = _contacts.cast<Contact?>().firstWhere(
|
||||
(c) => c?.publicKeyHex == contactPubKeyHex,
|
||||
orElse: () => null,
|
||||
);
|
||||
final isRoomServer = contact?.type == advTypeRoom;
|
||||
|
||||
ReactionHelper.applyReaction<Message>(
|
||||
messages: messages,
|
||||
reactionInfo: reactionInfo,
|
||||
// Incoming reactions in 1:1: match against outgoing messages only
|
||||
shouldSkip: (msg) => isRoomServer != true && !msg.isOutgoing,
|
||||
getTimestampSecs: (msg) => msg.timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
getSenderName: (msg) =>
|
||||
_resolveContactSenderName(msg, contact, isRoomServer == true),
|
||||
getMessageText: (msg) => msg.text,
|
||||
getReactions: (msg) => msg.reactions,
|
||||
updateMessage: (i, reactions) {
|
||||
messages[i] = messages[i].copyWith(reactions: reactions);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _processOutgoingContactReaction(
|
||||
List<Message> messages,
|
||||
ReactionInfo reactionInfo,
|
||||
Contact contact,
|
||||
) {
|
||||
final isRoomServer = contact.type == advTypeRoom;
|
||||
|
||||
ReactionHelper.applyReaction<Message>(
|
||||
messages: messages,
|
||||
reactionInfo: reactionInfo,
|
||||
// Outgoing reactions in 1:1: match against incoming messages
|
||||
shouldSkip: (msg) => !isRoomServer && msg.isOutgoing,
|
||||
getTimestampSecs: (msg) => msg.timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
getSenderName: (msg) =>
|
||||
_resolveContactSenderName(msg, contact, isRoomServer),
|
||||
getMessageText: (msg) => msg.text,
|
||||
getReactions: (msg) => msg.reactions,
|
||||
updateMessage: (i, reactions) {
|
||||
messages[i] = messages[i].copyWith(reactions: reactions);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _setReactionStatus(
|
||||
String pubKeyHex,
|
||||
ReactionInfo reactionInfo,
|
||||
MessageStatus status,
|
||||
) {
|
||||
final messages = _conversations[pubKeyHex];
|
||||
if (messages == null) return;
|
||||
final contact = _contacts.cast<Contact?>().firstWhere(
|
||||
(c) => c?.publicKeyHex == pubKeyHex,
|
||||
orElse: () => null,
|
||||
);
|
||||
final isRoomServer = contact?.type == advTypeRoom;
|
||||
for (int i = messages.length - 1; i >= 0; i--) {
|
||||
final msg = messages[i];
|
||||
|
||||
// For 1:1 chats: contact reacts to my outgoing messages only
|
||||
// For room servers: any message can be reacted to (multi-user)
|
||||
if (!isRoomServer && !msg.isOutgoing) continue;
|
||||
|
||||
final timestampSecs = msg.timestamp.millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
// For room servers, include sender name (resolve from fourByteRoomContactKey)
|
||||
// For 1:1 chats, sender is implicit (null)
|
||||
String? senderName;
|
||||
if (isRoomServer && !msg.isOutgoing) {
|
||||
final senderContact = _contacts.cast<Contact?>().firstWhere(
|
||||
(c) =>
|
||||
c != null &&
|
||||
_matchesPrefix(c.publicKey, msg.fourByteRoomContactKey),
|
||||
orElse: () => null,
|
||||
);
|
||||
senderName = senderContact?.name;
|
||||
} else if (isRoomServer && msg.isOutgoing) {
|
||||
senderName = selfName;
|
||||
}
|
||||
// For 1:1, senderName stays null
|
||||
|
||||
final msgHash = ReactionHelper.computeReactionHash(
|
||||
timestampSecs,
|
||||
senderName,
|
||||
_resolveContactSenderName(msg, contact, isRoomServer == true),
|
||||
msg.text,
|
||||
);
|
||||
if (msgHash == targetHash) {
|
||||
final currentReactions = Map<String, int>.from(msg.reactions);
|
||||
currentReactions[reactionInfo.emoji] =
|
||||
(currentReactions[reactionInfo.emoji] ?? 0) + 1;
|
||||
|
||||
messages[i] = msg.copyWith(reactions: currentReactions);
|
||||
if (msgHash == reactionInfo.targetHash) {
|
||||
final statuses = Map<String, MessageStatus>.from(msg.reactionStatuses);
|
||||
statuses[reactionInfo.emoji] = status;
|
||||
messages[i] = msg.copyWith(reactionStatuses: statuses);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String? _resolveContactSenderName(
|
||||
Message msg,
|
||||
Contact? contact,
|
||||
bool isRoomServer,
|
||||
) {
|
||||
if (!isRoomServer) return null;
|
||||
if (!msg.isOutgoing) {
|
||||
final senderContact = _contacts.cast<Contact?>().firstWhere(
|
||||
(c) =>
|
||||
c != null &&
|
||||
_matchesPrefix(c.publicKey, msg.fourByteRoomContactKey),
|
||||
orElse: () => null,
|
||||
);
|
||||
return senderContact?.name;
|
||||
}
|
||||
return selfName;
|
||||
}
|
||||
|
||||
_RawPacket? _parseRawPacket(Uint8List raw) {
|
||||
if (raw.length < 3) return null;
|
||||
var index = 0;
|
||||
|
|
@ -4077,10 +4243,11 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
index += 4;
|
||||
}
|
||||
if (raw.length <= index) return null;
|
||||
final pathLen = raw[index++];
|
||||
if (raw.length < index + pathLen) return null;
|
||||
final pathBytes = Uint8List.fromList(raw.sublist(index, index + pathLen));
|
||||
index += pathLen;
|
||||
final pathLenRaw = raw[index++];
|
||||
final pathByteLen = _decodePathByteLen(pathLenRaw);
|
||||
if (raw.length < index + pathByteLen) return null;
|
||||
final pathBytes = Uint8List.fromList(raw.sublist(index, index + pathByteLen));
|
||||
index += pathByteLen;
|
||||
if (raw.length <= index) return null;
|
||||
final payload = Uint8List.fromList(raw.sublist(index));
|
||||
|
||||
|
|
@ -4089,6 +4256,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
routeType: routeType,
|
||||
payloadType: (header >> _phTypeShift) & _phTypeMask,
|
||||
payloadVer: (header >> _phVerShift) & _phVerMask,
|
||||
pathLenRaw: pathLenRaw,
|
||||
pathBytes: pathBytes,
|
||||
payload: payload,
|
||||
);
|
||||
|
|
@ -4099,6 +4267,30 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
return digest[0];
|
||||
}
|
||||
|
||||
/// Firmware-compatible packet hash: SHA256(payloadType + payload) -> first 8 bytes as hex.
|
||||
String _computePacketHash(int payloadType, Uint8List payload) {
|
||||
final input = Uint8List(1 + payload.length);
|
||||
input[0] = payloadType;
|
||||
input.setRange(1, input.length, payload);
|
||||
final digest = crypto.sha256.convert(input).bytes;
|
||||
return digest.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
|
||||
/// Content-based dedup hash for sync queue messages (no raw payload available).
|
||||
/// Prefixed with 'c:' to avoid collisions with packet hashes.
|
||||
String _computeContentHash(int channelIdx, int timestampSecs, String fullText) {
|
||||
final textBytes = utf8.encode(fullText);
|
||||
final input = Uint8List(5 + textBytes.length);
|
||||
input[0] = channelIdx;
|
||||
input[1] = timestampSecs & 0xFF;
|
||||
input[2] = (timestampSecs >> 8) & 0xFF;
|
||||
input[3] = (timestampSecs >> 16) & 0xFF;
|
||||
input[4] = (timestampSecs >> 24) & 0xFF;
|
||||
input.setRange(5, 5 + textBytes.length, textBytes);
|
||||
final digest = crypto.sha256.convert(input).bytes;
|
||||
return 'c:${digest.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join()}';
|
||||
}
|
||||
|
||||
Uint8List? _decryptPayload(Uint8List psk, Uint8List encrypted) {
|
||||
if (encrypted.length <= _cipherMacSize) return null;
|
||||
final mac = encrypted.sublist(0, _cipherMacSize);
|
||||
|
|
@ -4146,63 +4338,6 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
return _ParsedText(senderName: 'Unknown', text: text);
|
||||
}
|
||||
|
||||
Uint8List _resolveOutgoingPathBytes(
|
||||
Contact contact,
|
||||
PathSelection? selection,
|
||||
) {
|
||||
// Priority 1: Check user's path override
|
||||
if (contact.pathOverride != null) {
|
||||
if (contact.pathOverride! < 0) {
|
||||
return Uint8List(0); // Force flood
|
||||
}
|
||||
return contact.pathOverrideBytes ?? Uint8List(0);
|
||||
}
|
||||
|
||||
// Priority 2: Check device flood mode or PathSelection flood
|
||||
if (contact.pathLength < 0 || selection?.useFlood == true) {
|
||||
return Uint8List(0);
|
||||
}
|
||||
|
||||
// Priority 3: Check PathSelection (auto-rotation)
|
||||
if (selection != null && selection.pathBytes.isNotEmpty) {
|
||||
return Uint8List.fromList(selection.pathBytes);
|
||||
}
|
||||
|
||||
// Priority 4: Use device's discovered path
|
||||
return contact.path;
|
||||
}
|
||||
|
||||
int? _resolveOutgoingPathLength(Contact contact, PathSelection? selection) {
|
||||
// Priority 1: Check user's path override
|
||||
if (contact.pathOverride != null) {
|
||||
return contact.pathOverride;
|
||||
}
|
||||
|
||||
// Priority 2: Check device flood mode or PathSelection flood
|
||||
if (contact.pathLength < 0 || selection?.useFlood == true) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Priority 3: Check PathSelection (auto-rotation)
|
||||
if (selection != null && selection.pathBytes.isNotEmpty) {
|
||||
return selection.hopCount;
|
||||
}
|
||||
|
||||
// Priority 4: Use device's discovered path
|
||||
return contact.pathLength;
|
||||
}
|
||||
|
||||
PathSelection _selectionFromPath(int pathLength, Uint8List pathBytes) {
|
||||
if (pathLength < 0) {
|
||||
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
|
||||
}
|
||||
return PathSelection(
|
||||
pathBytes: pathBytes,
|
||||
hopCount: pathLength,
|
||||
useFlood: false,
|
||||
);
|
||||
}
|
||||
|
||||
bool _addChannelMessage(int channelIndex, ChannelMessage message) {
|
||||
_channelMessages.putIfAbsent(channelIndex, () => []);
|
||||
final messages = _channelMessages[channelIndex]!;
|
||||
|
|
@ -4292,6 +4427,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
pathLength: mergedPathLength,
|
||||
pathBytes: mergedPathBytes,
|
||||
pathVariants: mergedPathVariants,
|
||||
packetHash: existing.packetHash ?? processedMessage.packetHash,
|
||||
// Mark as sent when first repeat is heard
|
||||
status: promotedFromPending
|
||||
? ChannelMessageStatus.sent
|
||||
|
|
@ -4326,35 +4462,38 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
List<ChannelMessage> messages,
|
||||
ReactionInfo reactionInfo,
|
||||
) {
|
||||
// Find target message by computing hash and comparing
|
||||
final targetHash = reactionInfo.targetHash;
|
||||
for (int i = messages.length - 1; i >= 0; i--) {
|
||||
final msg = messages[i];
|
||||
final timestampSecs = msg.timestamp.millisecondsSinceEpoch ~/ 1000;
|
||||
final msgHash = ReactionHelper.computeReactionHash(
|
||||
timestampSecs,
|
||||
msg.senderName,
|
||||
msg.text,
|
||||
);
|
||||
if (msgHash == targetHash) {
|
||||
final currentReactions = Map<String, int>.from(msg.reactions);
|
||||
currentReactions[reactionInfo.emoji] =
|
||||
(currentReactions[reactionInfo.emoji] ?? 0) + 1;
|
||||
|
||||
messages[i] = msg.copyWith(reactions: currentReactions);
|
||||
ReactionHelper.applyReaction<ChannelMessage>(
|
||||
messages: messages,
|
||||
reactionInfo: reactionInfo,
|
||||
shouldSkip: (_) => false,
|
||||
getTimestampSecs: (msg) => msg.timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
getSenderName: (msg) => msg.senderName,
|
||||
getMessageText: (msg) => msg.text,
|
||||
getReactions: (msg) => msg.reactions,
|
||||
updateMessage: (i, reactions) {
|
||||
messages[i] = messages[i].copyWith(reactions: reactions);
|
||||
notifyListeners();
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
int _findChannelRepeatIndex(
|
||||
List<ChannelMessage> messages,
|
||||
ChannelMessage incoming,
|
||||
) {
|
||||
// First pass: match by packet hash (exact dedup)
|
||||
final incomingHash = incoming.packetHash;
|
||||
if (incomingHash != null) {
|
||||
for (int i = messages.length - 1; i >= 0; i--) {
|
||||
final existingHash = messages[i].packetHash;
|
||||
if (existingHash != null && existingHash == incomingHash) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Second pass: heuristic fallback (outgoing echo, old messages without hash)
|
||||
for (int i = messages.length - 1; i >= 0; i--) {
|
||||
final existing = messages[i];
|
||||
if (_isChannelRepeat(existing, incoming)) {
|
||||
if (_isChannelRepeat(messages[i], incoming)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
|
@ -4368,7 +4507,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
(existing.timestamp.millisecondsSinceEpoch -
|
||||
incoming.timestamp.millisecondsSinceEpoch)
|
||||
.abs();
|
||||
if (diffMs > 5000) return false;
|
||||
if (diffMs > 30000) return false;
|
||||
|
||||
if (existing.senderName == incoming.senderName) return true;
|
||||
|
||||
|
|
@ -4613,8 +4752,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
packet.skipBytes(4); // Skip transport-specific bytes
|
||||
}
|
||||
//final payloadVer = (header >> 6) & 0x03;
|
||||
final pathLen = packet.readByte();
|
||||
final pathBytes = packet.readBytes(pathLen);
|
||||
final pathLenRaw = packet.readByte();
|
||||
final pathByteLen = _decodePathByteLen(pathLenRaw);
|
||||
final pathBytes = packet.readBytes(pathByteLen);
|
||||
final payload = packet.readBytes(packet.remaining);
|
||||
|
||||
final rawPacket = frame.sublist(3);
|
||||
|
|
@ -4652,8 +4792,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||
packet.skipBytes(4); // Skip transport-specific bytes
|
||||
}
|
||||
//final payloadVer = (header >> 6) & 0x03;
|
||||
final pathLen = packet.readByte();
|
||||
pathBytes = packet.readBytes(pathLen);
|
||||
final pathLenRaw = packet.readByte();
|
||||
final pathByteLen = _decodePathByteLen(pathLenRaw);
|
||||
pathBytes = packet.readBytes(pathByteLen);
|
||||
} catch (e) {
|
||||
appLogger.warn('Malformed RX frame: $e', tag: 'Connector');
|
||||
return;
|
||||
|
|
@ -4990,11 +5131,20 @@ const int _routeTransportDirect = 0x03;
|
|||
const int _payloadTypeGroupText = 0x05;
|
||||
const int _cipherMacSize = 2;
|
||||
|
||||
/// Decodes the firmware's encoded path_len byte into actual byte length.
|
||||
/// Bits 0-5: hash count (0-63), Bits 6-7: hash size code (0=1byte, 1=2bytes, 2=3bytes).
|
||||
int _decodePathByteLen(int pathLenRaw) {
|
||||
final hashCount = pathLenRaw & 63;
|
||||
final hashSize = ((pathLenRaw >> 6) & 0x03) + 1;
|
||||
return hashCount * hashSize;
|
||||
}
|
||||
|
||||
class _RawPacket {
|
||||
final int header;
|
||||
final int routeType;
|
||||
final int payloadType;
|
||||
final int payloadVer;
|
||||
final int pathLenRaw;
|
||||
final Uint8List pathBytes;
|
||||
final Uint8List payload;
|
||||
|
||||
|
|
@ -5003,12 +5153,15 @@ class _RawPacket {
|
|||
required this.routeType,
|
||||
required this.payloadType,
|
||||
required this.payloadVer,
|
||||
required this.pathLenRaw,
|
||||
required this.pathBytes,
|
||||
required this.payload,
|
||||
});
|
||||
|
||||
bool get isFlood =>
|
||||
routeType == _routeFlood || routeType == _routeTransportFlood;
|
||||
|
||||
int get hopCount => pathLenRaw & 63;
|
||||
}
|
||||
|
||||
class _ParsedText {
|
||||
|
|
|
|||
|
|
@ -509,7 +509,7 @@ Uint8List buildSendTextMsgFrame(
|
|||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSendTxtMsg);
|
||||
writer.writeByte(txtTypePlain);
|
||||
writer.writeByte(attempt.clamp(0, 3));
|
||||
writer.writeByte(attempt.clamp(0, 255));
|
||||
writer.writeUInt32LE(timestamp);
|
||||
writer.writeBytes(recipientPubKey.sublist(0, 6));
|
||||
writer.writeString(text);
|
||||
|
|
@ -838,7 +838,7 @@ Uint8List buildSendCliCommandFrame(
|
|||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSendTxtMsg);
|
||||
writer.writeByte(txtTypeCliData);
|
||||
writer.writeByte(attempt.clamp(0, 3));
|
||||
writer.writeByte(attempt.clamp(0, 255));
|
||||
writer.writeUInt32LE(timestamp);
|
||||
writer.writeBytes(repeaterPubKey.sublist(0, 6));
|
||||
writer.writeString(command);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,47 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../utils/platform_info.dart';
|
||||
|
||||
class LinkHandler {
|
||||
/// Returns a [SelectableLinkify] on desktop or a [Linkify] on mobile.
|
||||
static Widget buildLinkifyText({
|
||||
required BuildContext context,
|
||||
required String text,
|
||||
required TextStyle style,
|
||||
TextStyle? linkStyle,
|
||||
}) {
|
||||
final effectiveLinkStyle =
|
||||
linkStyle ??
|
||||
style.copyWith(
|
||||
color: Colors.green,
|
||||
decoration: TextDecoration.underline,
|
||||
);
|
||||
const options = LinkifyOptions(humanize: false, defaultToHttps: false);
|
||||
const linkifiers = [UrlLinkifier()];
|
||||
void onOpen(LinkableElement link) => handleLinkTap(context, link.url);
|
||||
|
||||
if (PlatformInfo.isDesktop) {
|
||||
return SelectableLinkify(
|
||||
text: text,
|
||||
style: style,
|
||||
linkStyle: effectiveLinkStyle,
|
||||
options: options,
|
||||
linkifiers: linkifiers,
|
||||
onOpen: onOpen,
|
||||
);
|
||||
}
|
||||
return Linkify(
|
||||
text: text,
|
||||
style: style,
|
||||
linkStyle: effectiveLinkStyle,
|
||||
options: options,
|
||||
linkifiers: linkifiers,
|
||||
onOpen: onOpen,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> handleLinkTap(BuildContext context, String url) async {
|
||||
// Show confirmation dialog
|
||||
final shouldOpen = await showDialog<bool>(
|
||||
|
|
|
|||
31
lib/helpers/path_helper.dart
Normal file
31
lib/helpers/path_helper.dart
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import '../models/contact.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
|
||||
class PathHelper {
|
||||
static String formatPathHex(List<int> pathBytes) {
|
||||
return pathBytes
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
||||
.join(',');
|
||||
}
|
||||
|
||||
static String resolvePathNames(
|
||||
List<int> pathBytes,
|
||||
List<Contact> allContacts,
|
||||
) {
|
||||
return pathBytes
|
||||
.map((b) {
|
||||
final hex = b.toRadixString(16).padLeft(2, '0').toUpperCase();
|
||||
final matches = allContacts
|
||||
.where(
|
||||
(c) =>
|
||||
c.publicKey.first == b &&
|
||||
(c.type == advTypeRepeater || c.type == advTypeRoom),
|
||||
)
|
||||
.toList();
|
||||
if (matches.isEmpty) return hex;
|
||||
if (matches.length == 1) return matches.first.name;
|
||||
return matches.map((c) => c.name).join(' | ');
|
||||
})
|
||||
.join(' \u2192 ');
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,50 @@ class ReactionInfo {
|
|||
}
|
||||
|
||||
class ReactionHelper {
|
||||
/// Apply a reaction to a list of messages by matching the reaction hash.
|
||||
///
|
||||
/// [messages] - the message list to search
|
||||
/// [reactionInfo] - the parsed reaction
|
||||
/// [getTimestampSecs] - extract timestamp seconds from a message
|
||||
/// [getSenderName] - extract sender name for hash (null for 1:1 implicit)
|
||||
/// [getMessageText] - extract message text
|
||||
/// [getReactions] - extract current reactions map
|
||||
/// [shouldSkip] - filter function to skip messages (e.g., skip outgoing for incoming reactions)
|
||||
/// [updateMessage] - callback to update the message at index with new reactions
|
||||
///
|
||||
/// Returns whether a match was found.
|
||||
static bool applyReaction<T>({
|
||||
required List<T> messages,
|
||||
required ReactionInfo reactionInfo,
|
||||
required int Function(T) getTimestampSecs,
|
||||
required String? Function(T) getSenderName,
|
||||
required String Function(T) getMessageText,
|
||||
required Map<String, int> Function(T) getReactions,
|
||||
required bool Function(T) shouldSkip,
|
||||
required void Function(int index, Map<String, int> newReactions)
|
||||
updateMessage,
|
||||
}) {
|
||||
final targetHash = reactionInfo.targetHash;
|
||||
for (int i = messages.length - 1; i >= 0; i--) {
|
||||
final msg = messages[i];
|
||||
if (shouldSkip(msg)) continue;
|
||||
|
||||
final msgHash = computeReactionHash(
|
||||
getTimestampSecs(msg),
|
||||
getSenderName(msg),
|
||||
getMessageText(msg),
|
||||
);
|
||||
if (msgHash == targetHash) {
|
||||
final currentReactions = Map<String, int>.from(getReactions(msg));
|
||||
currentReactions[reactionInfo.emoji] =
|
||||
(currentReactions[reactionInfo.emoji] ?? 0) + 1;
|
||||
updateMessage(i, currentReactions);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static List<String>? _cachedEmojis;
|
||||
|
||||
/// Combined list of all reaction emojis in fixed order.
|
||||
|
|
|
|||
|
|
@ -1889,5 +1889,26 @@
|
|||
"tcpErrorTimedOut": "Връзката TCP изтекла.",
|
||||
"tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}",
|
||||
"map_showDiscoveryContacts": "Покажи контакти за откриване",
|
||||
"map_setAsMyLocation": "Задайте като моя местоположение"
|
||||
"map_setAsMyLocation": "Задайте като моя местоположение",
|
||||
"@path_routeWeight": {
|
||||
"placeholders": {
|
||||
"weight": {
|
||||
"type": "String"
|
||||
},
|
||||
"max": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_initialRouteWeight": "Първоначална тежест на маршрута",
|
||||
"appSettings_maxRouteWeight": "Максимално допустимо тегло на маршрута",
|
||||
"appSettings_initialRouteWeightSubtitle": "Начално тегло за новооткрити маршрути",
|
||||
"appSettings_maxRouteWeightSubtitle": "Максималното тегло, което един маршрут може да събере от успешни доставки.",
|
||||
"appSettings_routeWeightSuccessIncrement": "Увеличение на теглото за успех",
|
||||
"appSettings_routeWeightSuccessIncrementSubtitle": "Тегло, добавено към път след успешно доставяне.",
|
||||
"appSettings_routeWeightFailureDecrement": "Намаляване на теглото, свързано с неуспех",
|
||||
"appSettings_routeWeightFailureDecrementSubtitle": "Тегло, което е било премахнато от пътя след неуспешен опит за доставка.",
|
||||
"appSettings_maxMessageRetries": "Максимален брой опити за изпращане на съобщение",
|
||||
"appSettings_maxMessageRetriesSubtitle": "Брой опити за повторно изпращане, преди съобщението да бъде маркирано като неуспешно.",
|
||||
"path_routeWeight": "{weight}/{max}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1917,5 +1917,26 @@
|
|||
"tcpErrorTimedOut": "Die TCP-Verbindung ist abgelaufen.",
|
||||
"tcpConnectionFailed": "Fehler beim TCP-Verbindungsaufbau: {error}",
|
||||
"map_showDiscoveryContacts": "Entdeckungs-Kontakte anzeigen",
|
||||
"map_setAsMyLocation": "Als meine aktuelle Position festlegen"
|
||||
"map_setAsMyLocation": "Als meine aktuelle Position festlegen",
|
||||
"@path_routeWeight": {
|
||||
"placeholders": {
|
||||
"weight": {
|
||||
"type": "String"
|
||||
},
|
||||
"max": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_initialRouteWeightSubtitle": "Ausgangsgewicht für neu entdeckte Pfade",
|
||||
"appSettings_maxRouteWeightSubtitle": "Maximales Gewicht, das ein Weg durch erfolgreiche Lieferungen erreichen kann.",
|
||||
"appSettings_maxRouteWeight": "Maximale Gesamtstreckenlänge",
|
||||
"appSettings_initialRouteWeight": "Anfangs-Streckengewicht",
|
||||
"appSettings_routeWeightSuccessIncrement": "Erhöhung des Erfolgsgewichts",
|
||||
"appSettings_routeWeightSuccessIncrementSubtitle": "Gewicht, das einem Pfad nach erfolgreicher Lieferung hinzugefügt wird.",
|
||||
"appSettings_routeWeightFailureDecrement": "Reduzierung des Gewichts bei Fehlern",
|
||||
"appSettings_routeWeightFailureDecrementSubtitle": "Gewicht, das nach einem fehlgeschlagenen Versand von einem Weg entfernt wurde",
|
||||
"appSettings_maxMessageRetries": "Maximale Anzahl an Wiederholungsversuchen",
|
||||
"appSettings_maxMessageRetriesSubtitle": "Anzahl der Versuche, eine Nachricht erneut zu senden, bevor sie als fehlgeschlagen markiert wird.",
|
||||
"path_routeWeight": "{weight}/{max}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -269,6 +269,23 @@
|
|||
"appSettings_autoRouteRotationSubtitle": "Cycle between best paths and flood mode",
|
||||
"appSettings_autoRouteRotationEnabled": "Auto route rotation enabled",
|
||||
"appSettings_autoRouteRotationDisabled": "Auto route rotation disabled",
|
||||
"appSettings_maxRouteWeight": "Max Route Weight",
|
||||
"appSettings_maxRouteWeightSubtitle": "Maximum weight a path can accumulate from successful deliveries",
|
||||
"appSettings_initialRouteWeight": "Initial Route Weight",
|
||||
"appSettings_initialRouteWeightSubtitle": "Starting weight for newly discovered paths",
|
||||
"appSettings_routeWeightSuccessIncrement": "Success Weight Increment",
|
||||
"appSettings_routeWeightSuccessIncrementSubtitle": "Weight added to a path after successful delivery",
|
||||
"appSettings_routeWeightFailureDecrement": "Failure Weight Decrement",
|
||||
"appSettings_routeWeightFailureDecrementSubtitle": "Weight removed from a path after failed delivery",
|
||||
"appSettings_maxMessageRetries": "Max Message Retries",
|
||||
"appSettings_maxMessageRetriesSubtitle": "Number of retry attempts before marking a message as failed",
|
||||
"path_routeWeight": "{weight}/{max}",
|
||||
"@path_routeWeight": {
|
||||
"placeholders": {
|
||||
"weight": { "type": "String" },
|
||||
"max": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"appSettings_battery": "Battery",
|
||||
"appSettings_batteryChemistry": "Battery Chemistry",
|
||||
"appSettings_batteryChemistryPerDevice": "Set per device ({deviceName})",
|
||||
|
|
|
|||
|
|
@ -1917,5 +1917,26 @@
|
|||
"tcpErrorTimedOut": "La conexión TCP ha caducado.",
|
||||
"tcpConnectionFailed": "Error en la conexión TCP: {error}",
|
||||
"map_showDiscoveryContacts": "Mostrar Contactos de Descubrimiento",
|
||||
"map_setAsMyLocation": "Establecer mi ubicación"
|
||||
"map_setAsMyLocation": "Establecer mi ubicación",
|
||||
"@path_routeWeight": {
|
||||
"placeholders": {
|
||||
"weight": {
|
||||
"type": "String"
|
||||
},
|
||||
"max": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_initialRouteWeight": "Peso inicial de la ruta",
|
||||
"appSettings_maxRouteWeight": "Peso máximo permitido para la ruta",
|
||||
"appSettings_initialRouteWeightSubtitle": "Peso inicial para rutas recién descubiertas",
|
||||
"appSettings_maxRouteWeightSubtitle": "Peso máximo que una ruta puede acumular gracias a entregas exitosas.",
|
||||
"appSettings_routeWeightSuccessIncrement": "Incremento de peso para el éxito",
|
||||
"appSettings_routeWeightSuccessIncrementSubtitle": "Peso añadido a una ruta después de una entrega exitosa.",
|
||||
"appSettings_routeWeightFailureDecrement": "Reducción del peso asociado al fallo",
|
||||
"appSettings_routeWeightFailureDecrementSubtitle": "Peso retirado de un camino después de un intento de entrega fallido.",
|
||||
"appSettings_maxMessageRetries": "Número máximo de reintentos de envío de mensajes",
|
||||
"appSettings_maxMessageRetriesSubtitle": "Número de intentos de reintento antes de marcar un mensaje como fallido.",
|
||||
"path_routeWeight": "{weight}/{max}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1889,5 +1889,26 @@
|
|||
"tcpErrorTimedOut": "La connexion TCP a expiré.",
|
||||
"tcpConnectionFailed": "Échec de la connexion TCP : {error}",
|
||||
"map_showDiscoveryContacts": "Afficher les contacts de découverte",
|
||||
"map_setAsMyLocation": "Définir comme ma localisation"
|
||||
"map_setAsMyLocation": "Définir comme ma localisation",
|
||||
"@path_routeWeight": {
|
||||
"placeholders": {
|
||||
"weight": {
|
||||
"type": "String"
|
||||
},
|
||||
"max": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_maxRouteWeightSubtitle": "Poids maximal qu'un itinéraire peut accumuler grâce à des livraisons réussies.",
|
||||
"appSettings_initialRouteWeight": "Poids initial de l'itinéraire",
|
||||
"appSettings_maxRouteWeight": "Poids maximal autorisé pour le trajet",
|
||||
"appSettings_initialRouteWeightSubtitle": "Poids de départ pour les nouveaux chemins découverts",
|
||||
"appSettings_routeWeightSuccessIncrement": "Augmentation du poids de réussite",
|
||||
"appSettings_routeWeightSuccessIncrementSubtitle": "Poids ajouté à un itinéraire après une livraison réussie.",
|
||||
"appSettings_routeWeightFailureDecrement": "Réduction du poids de pénalité",
|
||||
"appSettings_routeWeightFailureDecrementSubtitle": "Poids retiré d'un itinéraire après une tentative de livraison infructueuse.",
|
||||
"appSettings_maxMessageRetries": "Nombre maximal de tentatives de récupération de messages",
|
||||
"appSettings_maxMessageRetriesSubtitle": "Nombre de tentatives de relance avant de marquer un message comme ayant échoué.",
|
||||
"path_routeWeight": "{weight}/{max}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1889,5 +1889,26 @@
|
|||
"tcpErrorTimedOut": "La connessione TCP è scaduta.",
|
||||
"tcpConnectionFailed": "Impossibile stabilire la connessione TCP: {error}",
|
||||
"map_showDiscoveryContacts": "Mostra Contatti di Discovery",
|
||||
"map_setAsMyLocation": "Imposta come la mia posizione"
|
||||
"map_setAsMyLocation": "Imposta come la mia posizione",
|
||||
"@path_routeWeight": {
|
||||
"placeholders": {
|
||||
"weight": {
|
||||
"type": "String"
|
||||
},
|
||||
"max": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_initialRouteWeight": "Peso iniziale del percorso",
|
||||
"appSettings_initialRouteWeightSubtitle": "Peso di partenza per nuovi percorsi",
|
||||
"appSettings_maxRouteWeightSubtitle": "Il peso massimo che un percorso può accumulare grazie a consegne di successo.",
|
||||
"appSettings_maxRouteWeight": "Massimo peso consentito per il percorso",
|
||||
"appSettings_routeWeightSuccessIncrement": "Aumento del peso del successo",
|
||||
"appSettings_routeWeightSuccessIncrementSubtitle": "Peso aggiunto a un percorso dopo una consegna riuscita.",
|
||||
"appSettings_routeWeightFailureDecrement": "Riduzione del peso associato al fallimento",
|
||||
"appSettings_routeWeightFailureDecrementSubtitle": "Peso rimosso da un percorso dopo un tentativo di consegna fallito.",
|
||||
"appSettings_maxMessageRetries": "Numero massimo di tentativi di invio del messaggio",
|
||||
"appSettings_maxMessageRetriesSubtitle": "Numero di tentativi di riprova prima di considerare un messaggio come fallito.",
|
||||
"path_routeWeight": "{weight}/{max}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1360,6 +1360,72 @@ abstract class AppLocalizations {
|
|||
/// **'Auto route rotation disabled'**
|
||||
String get appSettings_autoRouteRotationDisabled;
|
||||
|
||||
/// No description provided for @appSettings_maxRouteWeight.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Max Route Weight'**
|
||||
String get appSettings_maxRouteWeight;
|
||||
|
||||
/// No description provided for @appSettings_maxRouteWeightSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Maximum weight a path can accumulate from successful deliveries'**
|
||||
String get appSettings_maxRouteWeightSubtitle;
|
||||
|
||||
/// No description provided for @appSettings_initialRouteWeight.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Initial Route Weight'**
|
||||
String get appSettings_initialRouteWeight;
|
||||
|
||||
/// No description provided for @appSettings_initialRouteWeightSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Starting weight for newly discovered paths'**
|
||||
String get appSettings_initialRouteWeightSubtitle;
|
||||
|
||||
/// No description provided for @appSettings_routeWeightSuccessIncrement.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Success Weight Increment'**
|
||||
String get appSettings_routeWeightSuccessIncrement;
|
||||
|
||||
/// No description provided for @appSettings_routeWeightSuccessIncrementSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Weight added to a path after successful delivery'**
|
||||
String get appSettings_routeWeightSuccessIncrementSubtitle;
|
||||
|
||||
/// No description provided for @appSettings_routeWeightFailureDecrement.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failure Weight Decrement'**
|
||||
String get appSettings_routeWeightFailureDecrement;
|
||||
|
||||
/// No description provided for @appSettings_routeWeightFailureDecrementSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Weight removed from a path after failed delivery'**
|
||||
String get appSettings_routeWeightFailureDecrementSubtitle;
|
||||
|
||||
/// No description provided for @appSettings_maxMessageRetries.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Max Message Retries'**
|
||||
String get appSettings_maxMessageRetries;
|
||||
|
||||
/// No description provided for @appSettings_maxMessageRetriesSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Number of retry attempts before marking a message as failed'**
|
||||
String get appSettings_maxMessageRetriesSubtitle;
|
||||
|
||||
/// No description provided for @path_routeWeight.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{weight}/{max}'**
|
||||
String path_routeWeight(String weight, String max);
|
||||
|
||||
/// No description provided for @appSettings_battery.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -695,6 +695,51 @@ class AppLocalizationsBg extends AppLocalizations {
|
|||
String get appSettings_autoRouteRotationDisabled =>
|
||||
'Автоматично маршрутизирането е деактивирано';
|
||||
|
||||
@override
|
||||
String get appSettings_maxRouteWeight =>
|
||||
'Максимално допустимо тегло на маршрута';
|
||||
|
||||
@override
|
||||
String get appSettings_maxRouteWeightSubtitle =>
|
||||
'Максималното тегло, което един маршрут може да събере от успешни доставки.';
|
||||
|
||||
@override
|
||||
String get appSettings_initialRouteWeight =>
|
||||
'Първоначална тежест на маршрута';
|
||||
|
||||
@override
|
||||
String get appSettings_initialRouteWeightSubtitle =>
|
||||
'Начално тегло за новооткрити маршрути';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightSuccessIncrement =>
|
||||
'Увеличение на теглото за успех';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightSuccessIncrementSubtitle =>
|
||||
'Тегло, добавено към път след успешно доставяне.';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightFailureDecrement =>
|
||||
'Намаляване на теглото, свързано с неуспех';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightFailureDecrementSubtitle =>
|
||||
'Тегло, което е било премахнато от пътя след неуспешен опит за доставка.';
|
||||
|
||||
@override
|
||||
String get appSettings_maxMessageRetries =>
|
||||
'Максимален брой опити за изпращане на съобщение';
|
||||
|
||||
@override
|
||||
String get appSettings_maxMessageRetriesSubtitle =>
|
||||
'Брой опити за повторно изпращане, преди съобщението да бъде маркирано като неуспешно.';
|
||||
|
||||
@override
|
||||
String path_routeWeight(String weight, String max) {
|
||||
return '$weight/$max';
|
||||
}
|
||||
|
||||
@override
|
||||
String get appSettings_battery => 'Батерия';
|
||||
|
||||
|
|
|
|||
|
|
@ -695,6 +695,49 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String get appSettings_autoRouteRotationDisabled =>
|
||||
'Automatische Routenrotation deaktiviert';
|
||||
|
||||
@override
|
||||
String get appSettings_maxRouteWeight => 'Maximale Gesamtstreckenlänge';
|
||||
|
||||
@override
|
||||
String get appSettings_maxRouteWeightSubtitle =>
|
||||
'Maximales Gewicht, das ein Weg durch erfolgreiche Lieferungen erreichen kann.';
|
||||
|
||||
@override
|
||||
String get appSettings_initialRouteWeight => 'Anfangs-Streckengewicht';
|
||||
|
||||
@override
|
||||
String get appSettings_initialRouteWeightSubtitle =>
|
||||
'Ausgangsgewicht für neu entdeckte Pfade';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightSuccessIncrement =>
|
||||
'Erhöhung des Erfolgsgewichts';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightSuccessIncrementSubtitle =>
|
||||
'Gewicht, das einem Pfad nach erfolgreicher Lieferung hinzugefügt wird.';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightFailureDecrement =>
|
||||
'Reduzierung des Gewichts bei Fehlern';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightFailureDecrementSubtitle =>
|
||||
'Gewicht, das nach einem fehlgeschlagenen Versand von einem Weg entfernt wurde';
|
||||
|
||||
@override
|
||||
String get appSettings_maxMessageRetries =>
|
||||
'Maximale Anzahl an Wiederholungsversuchen';
|
||||
|
||||
@override
|
||||
String get appSettings_maxMessageRetriesSubtitle =>
|
||||
'Anzahl der Versuche, eine Nachricht erneut zu senden, bevor sie als fehlgeschlagen markiert wird.';
|
||||
|
||||
@override
|
||||
String path_routeWeight(String weight, String max) {
|
||||
return '$weight/$max';
|
||||
}
|
||||
|
||||
@override
|
||||
String get appSettings_battery => 'Akku';
|
||||
|
||||
|
|
|
|||
|
|
@ -684,6 +684,48 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get appSettings_autoRouteRotationDisabled =>
|
||||
'Auto route rotation disabled';
|
||||
|
||||
@override
|
||||
String get appSettings_maxRouteWeight => 'Max Route Weight';
|
||||
|
||||
@override
|
||||
String get appSettings_maxRouteWeightSubtitle =>
|
||||
'Maximum weight a path can accumulate from successful deliveries';
|
||||
|
||||
@override
|
||||
String get appSettings_initialRouteWeight => 'Initial Route Weight';
|
||||
|
||||
@override
|
||||
String get appSettings_initialRouteWeightSubtitle =>
|
||||
'Starting weight for newly discovered paths';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightSuccessIncrement =>
|
||||
'Success Weight Increment';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightSuccessIncrementSubtitle =>
|
||||
'Weight added to a path after successful delivery';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightFailureDecrement =>
|
||||
'Failure Weight Decrement';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightFailureDecrementSubtitle =>
|
||||
'Weight removed from a path after failed delivery';
|
||||
|
||||
@override
|
||||
String get appSettings_maxMessageRetries => 'Max Message Retries';
|
||||
|
||||
@override
|
||||
String get appSettings_maxMessageRetriesSubtitle =>
|
||||
'Number of retry attempts before marking a message as failed';
|
||||
|
||||
@override
|
||||
String path_routeWeight(String weight, String max) {
|
||||
return '$weight/$max';
|
||||
}
|
||||
|
||||
@override
|
||||
String get appSettings_battery => 'Battery';
|
||||
|
||||
|
|
|
|||
|
|
@ -694,6 +694,49 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||
String get appSettings_autoRouteRotationDisabled =>
|
||||
'Rotación de ruta automática desactivada';
|
||||
|
||||
@override
|
||||
String get appSettings_maxRouteWeight => 'Peso máximo permitido para la ruta';
|
||||
|
||||
@override
|
||||
String get appSettings_maxRouteWeightSubtitle =>
|
||||
'Peso máximo que una ruta puede acumular gracias a entregas exitosas.';
|
||||
|
||||
@override
|
||||
String get appSettings_initialRouteWeight => 'Peso inicial de la ruta';
|
||||
|
||||
@override
|
||||
String get appSettings_initialRouteWeightSubtitle =>
|
||||
'Peso inicial para rutas recién descubiertas';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightSuccessIncrement =>
|
||||
'Incremento de peso para el éxito';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightSuccessIncrementSubtitle =>
|
||||
'Peso añadido a una ruta después de una entrega exitosa.';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightFailureDecrement =>
|
||||
'Reducción del peso asociado al fallo';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightFailureDecrementSubtitle =>
|
||||
'Peso retirado de un camino después de un intento de entrega fallido.';
|
||||
|
||||
@override
|
||||
String get appSettings_maxMessageRetries =>
|
||||
'Número máximo de reintentos de envío de mensajes';
|
||||
|
||||
@override
|
||||
String get appSettings_maxMessageRetriesSubtitle =>
|
||||
'Número de intentos de reintento antes de marcar un mensaje como fallido.';
|
||||
|
||||
@override
|
||||
String path_routeWeight(String weight, String max) {
|
||||
return '$weight/$max';
|
||||
}
|
||||
|
||||
@override
|
||||
String get appSettings_battery => 'Batería';
|
||||
|
||||
|
|
|
|||
|
|
@ -698,6 +698,50 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||
String get appSettings_autoRouteRotationDisabled =>
|
||||
'Rotation de l\'itinéraire automatique désactivée';
|
||||
|
||||
@override
|
||||
String get appSettings_maxRouteWeight =>
|
||||
'Poids maximal autorisé pour le trajet';
|
||||
|
||||
@override
|
||||
String get appSettings_maxRouteWeightSubtitle =>
|
||||
'Poids maximal qu\'un itinéraire peut accumuler grâce à des livraisons réussies.';
|
||||
|
||||
@override
|
||||
String get appSettings_initialRouteWeight => 'Poids initial de l\'itinéraire';
|
||||
|
||||
@override
|
||||
String get appSettings_initialRouteWeightSubtitle =>
|
||||
'Poids de départ pour les nouveaux chemins découverts';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightSuccessIncrement =>
|
||||
'Augmentation du poids de réussite';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightSuccessIncrementSubtitle =>
|
||||
'Poids ajouté à un itinéraire après une livraison réussie.';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightFailureDecrement =>
|
||||
'Réduction du poids de pénalité';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightFailureDecrementSubtitle =>
|
||||
'Poids retiré d\'un itinéraire après une tentative de livraison infructueuse.';
|
||||
|
||||
@override
|
||||
String get appSettings_maxMessageRetries =>
|
||||
'Nombre maximal de tentatives de récupération de messages';
|
||||
|
||||
@override
|
||||
String get appSettings_maxMessageRetriesSubtitle =>
|
||||
'Nombre de tentatives de relance avant de marquer un message comme ayant échoué.';
|
||||
|
||||
@override
|
||||
String path_routeWeight(String weight, String max) {
|
||||
return '$weight/$max';
|
||||
}
|
||||
|
||||
@override
|
||||
String get appSettings_battery => 'Batterie';
|
||||
|
||||
|
|
|
|||
|
|
@ -695,6 +695,50 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||
String get appSettings_autoRouteRotationDisabled =>
|
||||
'Rotazione del percorso automatico disabilitata';
|
||||
|
||||
@override
|
||||
String get appSettings_maxRouteWeight =>
|
||||
'Massimo peso consentito per il percorso';
|
||||
|
||||
@override
|
||||
String get appSettings_maxRouteWeightSubtitle =>
|
||||
'Il peso massimo che un percorso può accumulare grazie a consegne di successo.';
|
||||
|
||||
@override
|
||||
String get appSettings_initialRouteWeight => 'Peso iniziale del percorso';
|
||||
|
||||
@override
|
||||
String get appSettings_initialRouteWeightSubtitle =>
|
||||
'Peso di partenza per nuovi percorsi';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightSuccessIncrement =>
|
||||
'Aumento del peso del successo';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightSuccessIncrementSubtitle =>
|
||||
'Peso aggiunto a un percorso dopo una consegna riuscita.';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightFailureDecrement =>
|
||||
'Riduzione del peso associato al fallimento';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightFailureDecrementSubtitle =>
|
||||
'Peso rimosso da un percorso dopo un tentativo di consegna fallito.';
|
||||
|
||||
@override
|
||||
String get appSettings_maxMessageRetries =>
|
||||
'Numero massimo di tentativi di invio del messaggio';
|
||||
|
||||
@override
|
||||
String get appSettings_maxMessageRetriesSubtitle =>
|
||||
'Numero di tentativi di riprova prima di considerare un messaggio come fallito.';
|
||||
|
||||
@override
|
||||
String path_routeWeight(String weight, String max) {
|
||||
return '$weight/$max';
|
||||
}
|
||||
|
||||
@override
|
||||
String get appSettings_battery => 'Batteria';
|
||||
|
||||
|
|
|
|||
|
|
@ -689,6 +689,49 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||
String get appSettings_autoRouteRotationDisabled =>
|
||||
'Automatische route rotatie is uitgeschakeld';
|
||||
|
||||
@override
|
||||
String get appSettings_maxRouteWeight => 'Maximale gewicht voor de route';
|
||||
|
||||
@override
|
||||
String get appSettings_maxRouteWeightSubtitle =>
|
||||
'Het maximale gewicht dat een route kan bereiken door succesvolle leveringen.';
|
||||
|
||||
@override
|
||||
String get appSettings_initialRouteWeight => 'เริ่มต้น gewicht van de route';
|
||||
|
||||
@override
|
||||
String get appSettings_initialRouteWeightSubtitle =>
|
||||
'Startgewicht voor nieuwe, ontdekte routes';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightSuccessIncrement =>
|
||||
'Toename in het gewicht van het succes';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightSuccessIncrementSubtitle =>
|
||||
'Gewicht wordt toegevoegd aan een route na een succesvolle levering.';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightFailureDecrement =>
|
||||
'Vermindering van het gewicht van fouten';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightFailureDecrementSubtitle =>
|
||||
'Gewicht verwijderd van een pad na een mislukte levering';
|
||||
|
||||
@override
|
||||
String get appSettings_maxMessageRetries =>
|
||||
'Aantal pogingen om berichten te versturen';
|
||||
|
||||
@override
|
||||
String get appSettings_maxMessageRetriesSubtitle =>
|
||||
'Aantal pogingen om een bericht opnieuw te versturen voordat het als mislukt wordt gemarkeerd';
|
||||
|
||||
@override
|
||||
String path_routeWeight(String weight, String max) {
|
||||
return '$weight/$max';
|
||||
}
|
||||
|
||||
@override
|
||||
String get appSettings_battery => 'Batterij';
|
||||
|
||||
|
|
|
|||
|
|
@ -698,6 +698,49 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||
String get appSettings_autoRouteRotationDisabled =>
|
||||
'Automatyczne obracanie tras wyłączone';
|
||||
|
||||
@override
|
||||
String get appSettings_maxRouteWeight =>
|
||||
'Maksymalny dopuszczalny ciężar pojazdu';
|
||||
|
||||
@override
|
||||
String get appSettings_maxRouteWeightSubtitle =>
|
||||
'Maksymalna waga, jaką ścieżka może zgromadzić dzięki udanym dostawom.';
|
||||
|
||||
@override
|
||||
String get appSettings_initialRouteWeight => 'Początkowa waga trasy';
|
||||
|
||||
@override
|
||||
String get appSettings_initialRouteWeightSubtitle =>
|
||||
'Początkowa waga dla nowych, odkrytych ścieżek';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightSuccessIncrement => 'Wzrost wagi sukcesu';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightSuccessIncrementSubtitle =>
|
||||
'Waga dodana do ścieżki po pomyślnym dostarczeniu';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightFailureDecrement =>
|
||||
'Zmniejszenie wagi kary';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightFailureDecrementSubtitle =>
|
||||
'Waga usunięta z trasy po nieudanej dostawie';
|
||||
|
||||
@override
|
||||
String get appSettings_maxMessageRetries =>
|
||||
'Maksymalna liczba prób wysłania wiadomości';
|
||||
|
||||
@override
|
||||
String get appSettings_maxMessageRetriesSubtitle =>
|
||||
'Liczba prób ponownego wysłania wiadomości przed oznaczaniem jej jako nieudanej';
|
||||
|
||||
@override
|
||||
String path_routeWeight(String weight, String max) {
|
||||
return '$weight/$max';
|
||||
}
|
||||
|
||||
@override
|
||||
String get appSettings_battery => 'Bateria';
|
||||
|
||||
|
|
|
|||
|
|
@ -696,6 +696,49 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||
String get appSettings_autoRouteRotationDisabled =>
|
||||
'Rotação de roteamento automático desativada';
|
||||
|
||||
@override
|
||||
String get appSettings_maxRouteWeight => 'Peso Máximo da Rota';
|
||||
|
||||
@override
|
||||
String get appSettings_maxRouteWeightSubtitle =>
|
||||
'Peso máximo que um determinado percurso pode acumular com entregas bem-sucedidas.';
|
||||
|
||||
@override
|
||||
String get appSettings_initialRouteWeight => 'Peso Inicial da Rota';
|
||||
|
||||
@override
|
||||
String get appSettings_initialRouteWeightSubtitle =>
|
||||
'Peso inicial para novos caminhos descobertos';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightSuccessIncrement =>
|
||||
'Aumento do peso para indicar sucesso';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightSuccessIncrementSubtitle =>
|
||||
'Peso adicionado a um caminho após a entrega bem-sucedida.';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightFailureDecrement =>
|
||||
'Redução do peso da falha';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightFailureDecrementSubtitle =>
|
||||
'Peso removido de um caminho após uma tentativa de entrega malsucedida.';
|
||||
|
||||
@override
|
||||
String get appSettings_maxMessageRetries =>
|
||||
'Número máximo de tentativas de envio de mensagens';
|
||||
|
||||
@override
|
||||
String get appSettings_maxMessageRetriesSubtitle =>
|
||||
'Número de tentativas de reenvio antes de classificar uma mensagem como falha.';
|
||||
|
||||
@override
|
||||
String path_routeWeight(String weight, String max) {
|
||||
return '$weight/$max';
|
||||
}
|
||||
|
||||
@override
|
||||
String get appSettings_battery => 'Bateria';
|
||||
|
||||
|
|
|
|||
|
|
@ -696,6 +696,50 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||
String get appSettings_autoRouteRotationDisabled =>
|
||||
'Автоматическое переключение маршрутов отключено';
|
||||
|
||||
@override
|
||||
String get appSettings_maxRouteWeight =>
|
||||
'Максимальный допустимый вес маршрута';
|
||||
|
||||
@override
|
||||
String get appSettings_maxRouteWeightSubtitle =>
|
||||
'Максимальный вес, который может быть перевезён по определённому маршруту при успешных доставках.';
|
||||
|
||||
@override
|
||||
String get appSettings_initialRouteWeight => 'Начальный вес маршрута';
|
||||
|
||||
@override
|
||||
String get appSettings_initialRouteWeightSubtitle =>
|
||||
'Начальный вес для новых, только что открытых маршрутов';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightSuccessIncrement =>
|
||||
'Увеличение веса успеха';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightSuccessIncrementSubtitle =>
|
||||
'Вес, добавленный к маршруту после успешной доставки.';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightFailureDecrement =>
|
||||
'Уменьшение веса неудачи';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightFailureDecrementSubtitle =>
|
||||
'Вес, который был удален с пути после неудачной доставки.';
|
||||
|
||||
@override
|
||||
String get appSettings_maxMessageRetries =>
|
||||
'Максимальное количество повторных попыток отправки сообщения';
|
||||
|
||||
@override
|
||||
String get appSettings_maxMessageRetriesSubtitle =>
|
||||
'Количество попыток повторной отправки сообщения перед тем, как пометить его как неудачное.';
|
||||
|
||||
@override
|
||||
String path_routeWeight(String weight, String max) {
|
||||
return '$weight/$max';
|
||||
}
|
||||
|
||||
@override
|
||||
String get appSettings_battery => 'Батарея';
|
||||
|
||||
|
|
|
|||
|
|
@ -687,6 +687,48 @@ class AppLocalizationsSk extends AppLocalizations {
|
|||
String get appSettings_autoRouteRotationDisabled =>
|
||||
'Automatické prekladanie trás pozastavené';
|
||||
|
||||
@override
|
||||
String get appSettings_maxRouteWeight => 'Maximálna hmotnosť trasy';
|
||||
|
||||
@override
|
||||
String get appSettings_maxRouteWeightSubtitle =>
|
||||
'Maximálna hmotnosť, ktorú môže trás prenášať vďaka úspešným zásielkam.';
|
||||
|
||||
@override
|
||||
String get appSettings_initialRouteWeight => 'Počiatočná váha trasy';
|
||||
|
||||
@override
|
||||
String get appSettings_initialRouteWeightSubtitle =>
|
||||
'Počiatočná váha pre nové, objavené cesty';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightSuccessIncrement => 'Zvyšenie váhy úspechu';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightSuccessIncrementSubtitle =>
|
||||
'Hmotnosť pridaná k trase po úspešnej doručení';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightFailureDecrement =>
|
||||
'Sníženie váhy, ktorá sa používa na odhad rizika.';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightFailureDecrementSubtitle =>
|
||||
'Hmotnosť odstránená z cesty po neúspešnej doručenie';
|
||||
|
||||
@override
|
||||
String get appSettings_maxMessageRetries =>
|
||||
'Maximalný počet pokusov o doručenie správ';
|
||||
|
||||
@override
|
||||
String get appSettings_maxMessageRetriesSubtitle =>
|
||||
'Počet pokusov o odošleť pred označením správy ako neúspešnej';
|
||||
|
||||
@override
|
||||
String path_routeWeight(String weight, String max) {
|
||||
return '$weight/$max';
|
||||
}
|
||||
|
||||
@override
|
||||
String get appSettings_battery => 'Batéria';
|
||||
|
||||
|
|
|
|||
|
|
@ -687,6 +687,49 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||
String get appSettings_autoRouteRotationDisabled =>
|
||||
'Samodejno krmilno rotiranje je onemogočeno';
|
||||
|
||||
@override
|
||||
String get appSettings_maxRouteWeight => 'Največja dovoljena teža poti';
|
||||
|
||||
@override
|
||||
String get appSettings_maxRouteWeightSubtitle =>
|
||||
'Največja teža, ki jo lahko pot doseže s uspešnimi dostavnami.';
|
||||
|
||||
@override
|
||||
String get appSettings_initialRouteWeight => 'Izvirna teža poti';
|
||||
|
||||
@override
|
||||
String get appSettings_initialRouteWeightSubtitle =>
|
||||
'Izguba teže za nove, odkriti poti';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightSuccessIncrement =>
|
||||
'Učinkovitost: povečanje';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightSuccessIncrementSubtitle =>
|
||||
'Težava, dodana poti po uspešni dostavi';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightFailureDecrement =>
|
||||
'Zmanjšanje teže, ki je povezana s pomanjkanjem';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightFailureDecrementSubtitle =>
|
||||
'Težo, ki ni bila uspešno dostavljena, odstranili s poti.';
|
||||
|
||||
@override
|
||||
String get appSettings_maxMessageRetries =>
|
||||
'Najve število poskusov pošiljanja sporočil';
|
||||
|
||||
@override
|
||||
String get appSettings_maxMessageRetriesSubtitle =>
|
||||
'Število poskusov ponovnega poslanja, preden se sporočilo označuje kot neuspešno';
|
||||
|
||||
@override
|
||||
String path_routeWeight(String weight, String max) {
|
||||
return '$weight/$max';
|
||||
}
|
||||
|
||||
@override
|
||||
String get appSettings_battery => 'Baterija';
|
||||
|
||||
|
|
|
|||
|
|
@ -682,6 +682,48 @@ class AppLocalizationsSv extends AppLocalizations {
|
|||
String get appSettings_autoRouteRotationDisabled =>
|
||||
'Automatisk ruttrotation är avstängd';
|
||||
|
||||
@override
|
||||
String get appSettings_maxRouteWeight => 'Maximalt tillåtet vikt för rutten';
|
||||
|
||||
@override
|
||||
String get appSettings_maxRouteWeightSubtitle =>
|
||||
'Maximal vikt som en leveransväg kan ackumulera från framgångsrika leveranser.';
|
||||
|
||||
@override
|
||||
String get appSettings_initialRouteWeight => 'Initial vikt för rutt';
|
||||
|
||||
@override
|
||||
String get appSettings_initialRouteWeightSubtitle =>
|
||||
'Initial vikt för nyligen upptäckta vägar';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightSuccessIncrement =>
|
||||
'Ökning av vikt för framgång';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightSuccessIncrementSubtitle =>
|
||||
'Vikt läggs till en väg efter en lyckad leverans.';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightFailureDecrement =>
|
||||
'Minskning av vikten för misslyckande';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightFailureDecrementSubtitle =>
|
||||
'Vikt som tagits bort från en väg efter ett misslyckat leveransförsök';
|
||||
|
||||
@override
|
||||
String get appSettings_maxMessageRetries => 'Maximalt antal försök';
|
||||
|
||||
@override
|
||||
String get appSettings_maxMessageRetriesSubtitle =>
|
||||
'Antal försök att skicka om ett meddelande innan det markeras som misslyckat.';
|
||||
|
||||
@override
|
||||
String path_routeWeight(String weight, String max) {
|
||||
return '$weight/$max';
|
||||
}
|
||||
|
||||
@override
|
||||
String get appSettings_battery => 'Batteri';
|
||||
|
||||
|
|
|
|||
|
|
@ -692,6 +692,49 @@ class AppLocalizationsUk extends AppLocalizations {
|
|||
String get appSettings_autoRouteRotationDisabled =>
|
||||
'Авторотація маршрутизації вимкнена';
|
||||
|
||||
@override
|
||||
String get appSettings_maxRouteWeight => 'Максимальна вага маршруту';
|
||||
|
||||
@override
|
||||
String get appSettings_maxRouteWeightSubtitle =>
|
||||
'Максимальна вага, яку може накопичити маршрут завдяки успішним доставкам.';
|
||||
|
||||
@override
|
||||
String get appSettings_initialRouteWeight => 'Початкова вартість маршруту';
|
||||
|
||||
@override
|
||||
String get appSettings_initialRouteWeightSubtitle =>
|
||||
'Початкова вага для нових відкритих шляхів';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightSuccessIncrement =>
|
||||
'Збільшення ваги успіху';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightSuccessIncrementSubtitle =>
|
||||
'Вага, додана до маршруту після успішної доставки';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightFailureDecrement =>
|
||||
'Зменшення ваги помилки';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightFailureDecrementSubtitle =>
|
||||
'Вага, яка була знята з маршруту після невдалої доставки';
|
||||
|
||||
@override
|
||||
String get appSettings_maxMessageRetries =>
|
||||
'Максимальна кількість повторних спроб надсилання повідомлення';
|
||||
|
||||
@override
|
||||
String get appSettings_maxMessageRetriesSubtitle =>
|
||||
'Кількість спроб повторного відправлення повідомлення перед тим, як позначити його як невдале';
|
||||
|
||||
@override
|
||||
String path_routeWeight(String weight, String max) {
|
||||
return '$weight/$max';
|
||||
}
|
||||
|
||||
@override
|
||||
String get appSettings_battery => 'Батарея';
|
||||
|
||||
|
|
|
|||
|
|
@ -648,6 +648,43 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||
@override
|
||||
String get appSettings_autoRouteRotationDisabled => '自动路径轮换已禁用';
|
||||
|
||||
@override
|
||||
String get appSettings_maxRouteWeight => '最大路径重量';
|
||||
|
||||
@override
|
||||
String get appSettings_maxRouteWeightSubtitle => '一条路径可以累积的最大重量,取决于成功交付的数量。';
|
||||
|
||||
@override
|
||||
String get appSettings_initialRouteWeight => '初始路线权重';
|
||||
|
||||
@override
|
||||
String get appSettings_initialRouteWeightSubtitle => '新发现路径的初始重量';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightSuccessIncrement => '成功权重增加';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightSuccessIncrementSubtitle =>
|
||||
'在成功交付后,将重量添加到路径中';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightFailureDecrement => '失败权重降低';
|
||||
|
||||
@override
|
||||
String get appSettings_routeWeightFailureDecrementSubtitle =>
|
||||
'从一条路径上移除的货物,由于无法成功交付而移除。';
|
||||
|
||||
@override
|
||||
String get appSettings_maxMessageRetries => '最大消息重试次数';
|
||||
|
||||
@override
|
||||
String get appSettings_maxMessageRetriesSubtitle => '在将消息标记为失败之前,允许尝试的次数';
|
||||
|
||||
@override
|
||||
String path_routeWeight(String weight, String max) {
|
||||
return '$weight/$max';
|
||||
}
|
||||
|
||||
@override
|
||||
String get appSettings_battery => '电池';
|
||||
|
||||
|
|
|
|||
|
|
@ -1889,5 +1889,26 @@
|
|||
"tcpErrorTimedOut": "De TCP-verbinding is verlopen.",
|
||||
"tcpConnectionFailed": "Verbinding met TCP mislukt: {error}",
|
||||
"map_showDiscoveryContacts": "Ontdek contacten weergeven",
|
||||
"map_setAsMyLocation": "Stel dit in als mijn locatie"
|
||||
"map_setAsMyLocation": "Stel dit in als mijn locatie",
|
||||
"@path_routeWeight": {
|
||||
"placeholders": {
|
||||
"weight": {
|
||||
"type": "String"
|
||||
},
|
||||
"max": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_maxRouteWeightSubtitle": "Het maximale gewicht dat een route kan bereiken door succesvolle leveringen.",
|
||||
"appSettings_initialRouteWeight": "เริ่มต้น gewicht van de route",
|
||||
"appSettings_maxRouteWeight": "Maximale gewicht voor de route",
|
||||
"appSettings_initialRouteWeightSubtitle": "Startgewicht voor nieuwe, ontdekte routes",
|
||||
"appSettings_routeWeightSuccessIncrement": "Toename in het gewicht van het succes",
|
||||
"appSettings_routeWeightSuccessIncrementSubtitle": "Gewicht wordt toegevoegd aan een route na een succesvolle levering.",
|
||||
"appSettings_routeWeightFailureDecrement": "Vermindering van het gewicht van fouten",
|
||||
"appSettings_routeWeightFailureDecrementSubtitle": "Gewicht verwijderd van een pad na een mislukte levering",
|
||||
"appSettings_maxMessageRetries": "Aantal pogingen om berichten te versturen",
|
||||
"appSettings_maxMessageRetriesSubtitle": "Aantal pogingen om een bericht opnieuw te versturen voordat het als mislukt wordt gemarkeerd",
|
||||
"path_routeWeight": "{weight}/{max}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1889,5 +1889,26 @@
|
|||
"tcpErrorTimedOut": "Połączenie TCP zakończyło się bez powodzenia.",
|
||||
"tcpConnectionFailed": "Błąd połączenia TCP: {error}",
|
||||
"map_showDiscoveryContacts": "Pokaż kontakty odkrywania",
|
||||
"map_setAsMyLocation": "Ustaw jako moje lokalizację"
|
||||
"map_setAsMyLocation": "Ustaw jako moje lokalizację",
|
||||
"@path_routeWeight": {
|
||||
"placeholders": {
|
||||
"weight": {
|
||||
"type": "String"
|
||||
},
|
||||
"max": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_initialRouteWeight": "Początkowa waga trasy",
|
||||
"appSettings_maxRouteWeight": "Maksymalny dopuszczalny ciężar pojazdu",
|
||||
"appSettings_initialRouteWeightSubtitle": "Początkowa waga dla nowych, odkrytych ścieżek",
|
||||
"appSettings_maxRouteWeightSubtitle": "Maksymalna waga, jaką ścieżka może zgromadzić dzięki udanym dostawom.",
|
||||
"appSettings_routeWeightSuccessIncrement": "Wzrost wagi sukcesu",
|
||||
"appSettings_routeWeightSuccessIncrementSubtitle": "Waga dodana do ścieżki po pomyślnym dostarczeniu",
|
||||
"appSettings_routeWeightFailureDecrement": "Zmniejszenie wagi kary",
|
||||
"appSettings_routeWeightFailureDecrementSubtitle": "Waga usunięta z trasy po nieudanej dostawie",
|
||||
"appSettings_maxMessageRetries": "Maksymalna liczba prób wysłania wiadomości",
|
||||
"appSettings_maxMessageRetriesSubtitle": "Liczba prób ponownego wysłania wiadomości przed oznaczaniem jej jako nieudanej",
|
||||
"path_routeWeight": "{weight}/{max}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1889,5 +1889,26 @@
|
|||
"tcpErrorTimedOut": "A conexão TCP expirou.",
|
||||
"tcpConnectionFailed": "Falha na conexão TCP: {error}",
|
||||
"map_showDiscoveryContacts": "Mostrar Contatos de Descoberta",
|
||||
"map_setAsMyLocation": "Defina minha localização"
|
||||
"map_setAsMyLocation": "Defina minha localização",
|
||||
"@path_routeWeight": {
|
||||
"placeholders": {
|
||||
"weight": {
|
||||
"type": "String"
|
||||
},
|
||||
"max": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_initialRouteWeight": "Peso Inicial da Rota",
|
||||
"appSettings_maxRouteWeight": "Peso Máximo da Rota",
|
||||
"appSettings_maxRouteWeightSubtitle": "Peso máximo que um determinado percurso pode acumular com entregas bem-sucedidas.",
|
||||
"appSettings_initialRouteWeightSubtitle": "Peso inicial para novos caminhos descobertos",
|
||||
"appSettings_routeWeightSuccessIncrement": "Aumento do peso para indicar sucesso",
|
||||
"appSettings_routeWeightSuccessIncrementSubtitle": "Peso adicionado a um caminho após a entrega bem-sucedida.",
|
||||
"appSettings_routeWeightFailureDecrement": "Redução do peso da falha",
|
||||
"appSettings_routeWeightFailureDecrementSubtitle": "Peso removido de um caminho após uma tentativa de entrega malsucedida.",
|
||||
"appSettings_maxMessageRetries": "Número máximo de tentativas de envio de mensagens",
|
||||
"appSettings_maxMessageRetriesSubtitle": "Número de tentativas de reenvio antes de classificar uma mensagem como falha.",
|
||||
"path_routeWeight": "{weight}/{max}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1129,5 +1129,26 @@
|
|||
"tcpErrorTimedOut": "Соединение TCP не удалось установить.",
|
||||
"tcpConnectionFailed": "Не удалось установить соединение TCP: {error}",
|
||||
"map_showDiscoveryContacts": "Показать контакты Discovery",
|
||||
"map_setAsMyLocation": "Установить мое местоположение"
|
||||
"map_setAsMyLocation": "Установить мое местоположение",
|
||||
"@path_routeWeight": {
|
||||
"placeholders": {
|
||||
"weight": {
|
||||
"type": "String"
|
||||
},
|
||||
"max": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_maxRouteWeight": "Максимальный допустимый вес маршрута",
|
||||
"appSettings_maxRouteWeightSubtitle": "Максимальный вес, который может быть перевезён по определённому маршруту при успешных доставках.",
|
||||
"appSettings_initialRouteWeightSubtitle": "Начальный вес для новых, только что открытых маршрутов",
|
||||
"appSettings_initialRouteWeight": "Начальный вес маршрута",
|
||||
"appSettings_routeWeightSuccessIncrement": "Увеличение веса успеха",
|
||||
"appSettings_routeWeightSuccessIncrementSubtitle": "Вес, добавленный к маршруту после успешной доставки.",
|
||||
"appSettings_routeWeightFailureDecrement": "Уменьшение веса неудачи",
|
||||
"appSettings_routeWeightFailureDecrementSubtitle": "Вес, который был удален с пути после неудачной доставки.",
|
||||
"appSettings_maxMessageRetries": "Максимальное количество повторных попыток отправки сообщения",
|
||||
"appSettings_maxMessageRetriesSubtitle": "Количество попыток повторной отправки сообщения перед тем, как пометить его как неудачное.",
|
||||
"path_routeWeight": "{weight}/{max}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1889,5 +1889,26 @@
|
|||
"tcpErrorTimedOut": "Pripojenie TCP vypršalo.",
|
||||
"tcpConnectionFailed": "Neúspešné vytvorenie TCP spojenia: {error}",
|
||||
"map_showDiscoveryContacts": "Zobraziť kontakty objavov",
|
||||
"map_setAsMyLocation": "Nastavte ako moju polohu"
|
||||
"map_setAsMyLocation": "Nastavte ako moju polohu",
|
||||
"@path_routeWeight": {
|
||||
"placeholders": {
|
||||
"weight": {
|
||||
"type": "String"
|
||||
},
|
||||
"max": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_maxRouteWeightSubtitle": "Maximálna hmotnosť, ktorú môže trás prenášať vďaka úspešným zásielkam.",
|
||||
"appSettings_initialRouteWeightSubtitle": "Počiatočná váha pre nové, objavené cesty",
|
||||
"appSettings_initialRouteWeight": "Počiatočná váha trasy",
|
||||
"appSettings_maxRouteWeight": "Maximálna hmotnosť trasy",
|
||||
"appSettings_routeWeightSuccessIncrement": "Zvyšenie váhy úspechu",
|
||||
"appSettings_routeWeightSuccessIncrementSubtitle": "Hmotnosť pridaná k trase po úspešnej doručení",
|
||||
"appSettings_routeWeightFailureDecrement": "Sníženie váhy, ktorá sa používa na odhad rizika.",
|
||||
"appSettings_routeWeightFailureDecrementSubtitle": "Hmotnosť odstránená z cesty po neúspešnej doručenie",
|
||||
"appSettings_maxMessageRetries": "Maximalný počet pokusov o doručenie správ",
|
||||
"appSettings_maxMessageRetriesSubtitle": "Počet pokusov o odošleť pred označením správy ako neúspešnej",
|
||||
"path_routeWeight": "{weight}/{max}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1889,5 +1889,26 @@
|
|||
"tcpErrorTimedOut": "Povezava TCP je presegla časovno obdobje.",
|
||||
"tcpConnectionFailed": "Napaka pri povezavi TCP: {error}",
|
||||
"map_showDiscoveryContacts": "Prikaži odkritja kontaktov",
|
||||
"map_setAsMyLocation": "Nastavite to kot mojo lokacijo"
|
||||
"map_setAsMyLocation": "Nastavite to kot mojo lokacijo",
|
||||
"@path_routeWeight": {
|
||||
"placeholders": {
|
||||
"weight": {
|
||||
"type": "String"
|
||||
},
|
||||
"max": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_maxRouteWeightSubtitle": "Največja teža, ki jo lahko pot doseže s uspešnimi dostavnami.",
|
||||
"appSettings_initialRouteWeight": "Izvirna teža poti",
|
||||
"appSettings_initialRouteWeightSubtitle": "Izguba teže za nove, odkriti poti",
|
||||
"appSettings_maxRouteWeight": "Največja dovoljena teža poti",
|
||||
"appSettings_routeWeightSuccessIncrement": "Učinkovitost: povečanje",
|
||||
"appSettings_routeWeightSuccessIncrementSubtitle": "Težava, dodana poti po uspešni dostavi",
|
||||
"appSettings_routeWeightFailureDecrement": "Zmanjšanje teže, ki je povezana s pomanjkanjem",
|
||||
"appSettings_routeWeightFailureDecrementSubtitle": "Težo, ki ni bila uspešno dostavljena, odstranili s poti.",
|
||||
"appSettings_maxMessageRetries": "Najve število poskusov pošiljanja sporočil",
|
||||
"appSettings_maxMessageRetriesSubtitle": "Število poskusov ponovnega poslanja, preden se sporočilo označuje kot neuspešno",
|
||||
"path_routeWeight": "{weight}/{max}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1889,5 +1889,26 @@
|
|||
"tcpErrorTimedOut": "TCP-anslutningen har tidsut gått.",
|
||||
"tcpConnectionFailed": "Fel vid TCP-anslutning: {error}",
|
||||
"map_showDiscoveryContacts": "Visa Discovery-kontakter",
|
||||
"map_setAsMyLocation": "Ange som min plats"
|
||||
"map_setAsMyLocation": "Ange som min plats",
|
||||
"@path_routeWeight": {
|
||||
"placeholders": {
|
||||
"weight": {
|
||||
"type": "String"
|
||||
},
|
||||
"max": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_initialRouteWeightSubtitle": "Initial vikt för nyligen upptäckta vägar",
|
||||
"appSettings_maxRouteWeight": "Maximalt tillåtet vikt för rutten",
|
||||
"appSettings_maxRouteWeightSubtitle": "Maximal vikt som en leveransväg kan ackumulera från framgångsrika leveranser.",
|
||||
"appSettings_initialRouteWeight": "Initial vikt för rutt",
|
||||
"appSettings_routeWeightSuccessIncrement": "Ökning av vikt för framgång",
|
||||
"appSettings_routeWeightSuccessIncrementSubtitle": "Vikt läggs till en väg efter en lyckad leverans.",
|
||||
"appSettings_routeWeightFailureDecrement": "Minskning av vikten för misslyckande",
|
||||
"appSettings_routeWeightFailureDecrementSubtitle": "Vikt som tagits bort från en väg efter ett misslyckat leveransförsök",
|
||||
"appSettings_maxMessageRetries": "Maximalt antal försök",
|
||||
"appSettings_maxMessageRetriesSubtitle": "Antal försök att skicka om ett meddelande innan det markeras som misslyckat.",
|
||||
"path_routeWeight": "{weight}/{max}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1889,5 +1889,26 @@
|
|||
"tcpErrorTimedOut": "З'єднання TCP завершилося через закінчення часу очікування.",
|
||||
"tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}",
|
||||
"map_showDiscoveryContacts": "Показати контакти Відкриття",
|
||||
"map_setAsMyLocation": "Встановити моє місцезнаходження"
|
||||
"map_setAsMyLocation": "Встановити моє місцезнаходження",
|
||||
"@path_routeWeight": {
|
||||
"placeholders": {
|
||||
"weight": {
|
||||
"type": "String"
|
||||
},
|
||||
"max": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_initialRouteWeight": "Початкова вартість маршруту",
|
||||
"appSettings_initialRouteWeightSubtitle": "Початкова вага для нових відкритих шляхів",
|
||||
"appSettings_maxRouteWeight": "Максимальна вага маршруту",
|
||||
"appSettings_maxRouteWeightSubtitle": "Максимальна вага, яку може накопичити маршрут завдяки успішним доставкам.",
|
||||
"appSettings_routeWeightSuccessIncrement": "Збільшення ваги успіху",
|
||||
"appSettings_routeWeightSuccessIncrementSubtitle": "Вага, додана до маршруту після успішної доставки",
|
||||
"appSettings_routeWeightFailureDecrement": "Зменшення ваги помилки",
|
||||
"appSettings_routeWeightFailureDecrementSubtitle": "Вага, яка була знята з маршруту після невдалої доставки",
|
||||
"appSettings_maxMessageRetries": "Максимальна кількість повторних спроб надсилання повідомлення",
|
||||
"appSettings_maxMessageRetriesSubtitle": "Кількість спроб повторного відправлення повідомлення перед тим, як позначити його як невдале",
|
||||
"path_routeWeight": "{weight}/{max}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1894,5 +1894,26 @@
|
|||
"tcpErrorTimedOut": "TCP 连接超时。",
|
||||
"tcpConnectionFailed": "TCP 连接失败:{error}",
|
||||
"map_showDiscoveryContacts": "显示发现联系人",
|
||||
"map_setAsMyLocation": "设置为我的位置"
|
||||
"map_setAsMyLocation": "设置为我的位置",
|
||||
"@path_routeWeight": {
|
||||
"placeholders": {
|
||||
"weight": {
|
||||
"type": "String"
|
||||
},
|
||||
"max": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_maxRouteWeight": "最大路径重量",
|
||||
"appSettings_initialRouteWeightSubtitle": "新发现路径的初始重量",
|
||||
"appSettings_initialRouteWeight": "初始路线权重",
|
||||
"appSettings_maxRouteWeightSubtitle": "一条路径可以累积的最大重量,取决于成功交付的数量。",
|
||||
"appSettings_routeWeightSuccessIncrement": "成功权重增加",
|
||||
"appSettings_routeWeightSuccessIncrementSubtitle": "在成功交付后,将重量添加到路径中",
|
||||
"appSettings_routeWeightFailureDecrement": "失败权重降低",
|
||||
"appSettings_routeWeightFailureDecrementSubtitle": "从一条路径上移除的货物,由于无法成功交付而移除。",
|
||||
"appSettings_maxMessageRetries": "最大消息重试次数",
|
||||
"appSettings_maxMessageRetriesSubtitle": "在将消息标记为失败之前,允许尝试的次数",
|
||||
"path_routeWeight": "{weight}/{max}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,11 @@ class AppSettings {
|
|||
final bool notifyOnNewChannelMessage;
|
||||
final bool notifyOnNewAdvert;
|
||||
final bool autoRouteRotationEnabled;
|
||||
final double maxRouteWeight;
|
||||
final double initialRouteWeight;
|
||||
final double routeWeightSuccessIncrement;
|
||||
final double routeWeightFailureDecrement;
|
||||
final int maxMessageRetries;
|
||||
final String themeMode;
|
||||
final String? languageOverride; // null = system default
|
||||
final bool appDebugLogEnabled;
|
||||
|
|
@ -62,6 +67,11 @@ class AppSettings {
|
|||
this.notifyOnNewChannelMessage = true,
|
||||
this.notifyOnNewAdvert = true,
|
||||
this.autoRouteRotationEnabled = false,
|
||||
this.maxRouteWeight = 5.0,
|
||||
this.initialRouteWeight = 3.0,
|
||||
this.routeWeightSuccessIncrement = 0.5,
|
||||
this.routeWeightFailureDecrement = 0.2,
|
||||
this.maxMessageRetries = 5,
|
||||
this.themeMode = 'system',
|
||||
this.languageOverride,
|
||||
this.appDebugLogEnabled = false,
|
||||
|
|
@ -96,6 +106,11 @@ class AppSettings {
|
|||
'notify_on_new_channel_message': notifyOnNewChannelMessage,
|
||||
'notify_on_new_advert': notifyOnNewAdvert,
|
||||
'auto_route_rotation_enabled': autoRouteRotationEnabled,
|
||||
'max_route_weight': maxRouteWeight,
|
||||
'initial_route_weight': initialRouteWeight,
|
||||
'route_weight_success_increment': routeWeightSuccessIncrement,
|
||||
'route_weight_failure_decrement': routeWeightFailureDecrement,
|
||||
'max_message_retries': maxMessageRetries,
|
||||
'theme_mode': themeMode,
|
||||
'language_override': languageOverride,
|
||||
'app_debug_log_enabled': appDebugLogEnabled,
|
||||
|
|
@ -142,6 +157,14 @@ class AppSettings {
|
|||
notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true,
|
||||
autoRouteRotationEnabled:
|
||||
json['auto_route_rotation_enabled'] as bool? ?? false,
|
||||
maxRouteWeight: (json['max_route_weight'] as num?)?.toDouble() ?? 5.0,
|
||||
initialRouteWeight:
|
||||
(json['initial_route_weight'] as num?)?.toDouble() ?? 3.0,
|
||||
routeWeightSuccessIncrement:
|
||||
(json['route_weight_success_increment'] as num?)?.toDouble() ?? 0.5,
|
||||
routeWeightFailureDecrement:
|
||||
(json['route_weight_failure_decrement'] as num?)?.toDouble() ?? 0.2,
|
||||
maxMessageRetries: json['max_message_retries'] as int? ?? 5,
|
||||
themeMode: json['theme_mode'] as String? ?? 'system',
|
||||
languageOverride: json['language_override'] as String?,
|
||||
appDebugLogEnabled: json['app_debug_log_enabled'] as bool? ?? false,
|
||||
|
|
@ -187,6 +210,11 @@ class AppSettings {
|
|||
bool? notifyOnNewChannelMessage,
|
||||
bool? notifyOnNewAdvert,
|
||||
bool? autoRouteRotationEnabled,
|
||||
double? maxRouteWeight,
|
||||
double? initialRouteWeight,
|
||||
double? routeWeightSuccessIncrement,
|
||||
double? routeWeightFailureDecrement,
|
||||
int? maxMessageRetries,
|
||||
String? themeMode,
|
||||
Object? languageOverride = _unset,
|
||||
bool? appDebugLogEnabled,
|
||||
|
|
@ -222,6 +250,13 @@ class AppSettings {
|
|||
notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert,
|
||||
autoRouteRotationEnabled:
|
||||
autoRouteRotationEnabled ?? this.autoRouteRotationEnabled,
|
||||
maxRouteWeight: maxRouteWeight ?? this.maxRouteWeight,
|
||||
initialRouteWeight: initialRouteWeight ?? this.initialRouteWeight,
|
||||
routeWeightSuccessIncrement:
|
||||
routeWeightSuccessIncrement ?? this.routeWeightSuccessIncrement,
|
||||
routeWeightFailureDecrement:
|
||||
routeWeightFailureDecrement ?? this.routeWeightFailureDecrement,
|
||||
maxMessageRetries: maxMessageRetries ?? this.maxMessageRetries,
|
||||
themeMode: themeMode ?? this.themeMode,
|
||||
languageOverride: languageOverride == _unset
|
||||
? this.languageOverride
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ class ChannelMessage {
|
|||
final List<Uint8List> pathVariants;
|
||||
final int? channelIndex;
|
||||
final String messageId;
|
||||
final String? packetHash;
|
||||
final String? replyToMessageId;
|
||||
final String? replyToSenderName;
|
||||
final String? replyToText;
|
||||
|
|
@ -55,6 +56,7 @@ class ChannelMessage {
|
|||
List<Uint8List>? pathVariants,
|
||||
this.channelIndex,
|
||||
String? messageId,
|
||||
this.packetHash,
|
||||
this.replyToMessageId,
|
||||
this.replyToSenderName,
|
||||
this.replyToText,
|
||||
|
|
@ -79,6 +81,7 @@ class ChannelMessage {
|
|||
int? pathLength,
|
||||
Uint8List? pathBytes,
|
||||
List<Uint8List>? pathVariants,
|
||||
String? packetHash,
|
||||
String? replyToMessageId,
|
||||
String? replyToSenderName,
|
||||
String? replyToText,
|
||||
|
|
@ -98,6 +101,7 @@ class ChannelMessage {
|
|||
pathVariants: pathVariants ?? this.pathVariants,
|
||||
channelIndex: channelIndex,
|
||||
messageId: messageId,
|
||||
packetHash: packetHash ?? this.packetHash,
|
||||
replyToMessageId: replyToMessageId ?? this.replyToMessageId,
|
||||
replyToSenderName: replyToSenderName ?? this.replyToSenderName,
|
||||
replyToText: replyToText ?? this.replyToText,
|
||||
|
|
|
|||
|
|
@ -157,6 +157,12 @@ class Contact {
|
|||
return null;
|
||||
}
|
||||
final pubKey = reader.readBytes(pubKeySize);
|
||||
|
||||
// Guard: reject contacts with zeroed or mostly-zeroed public keys
|
||||
// (indicates corrupt flash storage on the firmware side)
|
||||
final zeroCount = pubKey.where((b) => b == 0).length;
|
||||
if (zeroCount > pubKeySize ~/ 2) return null;
|
||||
|
||||
final type = reader.readByte();
|
||||
final flags = reader.readByte();
|
||||
final pathLen = reader.readByte();
|
||||
|
|
@ -166,6 +172,12 @@ class Contact {
|
|||
final pathBytes = reader.readBytes(maxPathSize).sublist(0, safePathLen);
|
||||
final name = reader.readCStringGreedy(maxNameSize);
|
||||
|
||||
// Guard: reject contacts with non-printable names (corrupt flash data)
|
||||
if (name.isNotEmpty &&
|
||||
name.codeUnits.every((c) => c < 0x20 || c == 0xFFFD)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final lastMod = reader.readUInt32LE();
|
||||
|
||||
double? lat, lon;
|
||||
|
|
@ -182,7 +194,7 @@ class Contact {
|
|||
name: name.isEmpty ? 'Unknown' : name,
|
||||
type: type,
|
||||
flags: flags,
|
||||
pathLength: pathLen > 0 ? (pathLen > maxPathSize ? -1 : pathLen) : -1,
|
||||
pathLength: (pathLen == 0xFF || pathLen > maxPathSize) ? -1 : pathLen,
|
||||
path: pathBytes,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class Message {
|
|||
final int? pathLength;
|
||||
final Uint8List pathBytes;
|
||||
final Map<String, int> reactions;
|
||||
final Map<String, MessageStatus> reactionStatuses;
|
||||
final Uint8List fourByteRoomContactKey;
|
||||
|
||||
Message({
|
||||
|
|
@ -43,9 +44,11 @@ class Message {
|
|||
Uint8List? pathBytes,
|
||||
Uint8List? fourByteRoomContactKey,
|
||||
Map<String, int>? reactions,
|
||||
Map<String, MessageStatus>? reactionStatuses,
|
||||
}) : pathBytes = pathBytes ?? Uint8List(0),
|
||||
fourByteRoomContactKey = fourByteRoomContactKey ?? Uint8List(0),
|
||||
reactions = reactions ?? {};
|
||||
reactions = reactions ?? {},
|
||||
reactionStatuses = reactionStatuses ?? {};
|
||||
|
||||
String get senderKeyHex => pubKeyToHex(senderKey);
|
||||
|
||||
|
|
@ -61,6 +64,7 @@ class Message {
|
|||
Uint8List? pathBytes,
|
||||
bool? isCli,
|
||||
Map<String, int>? reactions,
|
||||
Map<String, MessageStatus>? reactionStatuses,
|
||||
Uint8List? fourByteRoomContactKey,
|
||||
}) {
|
||||
return Message(
|
||||
|
|
@ -80,6 +84,7 @@ class Message {
|
|||
pathLength: pathLength ?? this.pathLength,
|
||||
pathBytes: pathBytes ?? this.pathBytes,
|
||||
reactions: reactions ?? this.reactions,
|
||||
reactionStatuses: reactionStatuses ?? this.reactionStatuses,
|
||||
fourByteRoomContactKey:
|
||||
fourByteRoomContactKey ?? this.fourByteRoomContactKey,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
class PathRecord {
|
||||
final int hopCount;
|
||||
final int tripTimeMs;
|
||||
final DateTime timestamp;
|
||||
final DateTime? timestamp;
|
||||
final bool wasFloodDiscovery;
|
||||
final List<int> pathBytes;
|
||||
final int successCount;
|
||||
final int failureCount;
|
||||
final double routeWeight;
|
||||
|
||||
PathRecord({
|
||||
required this.hopCount,
|
||||
|
|
@ -15,6 +16,7 @@ class PathRecord {
|
|||
required this.pathBytes,
|
||||
required this.successCount,
|
||||
required this.failureCount,
|
||||
this.routeWeight = 1.0,
|
||||
});
|
||||
|
||||
String get displayText =>
|
||||
|
|
@ -24,11 +26,12 @@ class PathRecord {
|
|||
return {
|
||||
'hop_count': hopCount,
|
||||
'trip_time_ms': tripTimeMs,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'timestamp': timestamp?.toIso8601String(),
|
||||
'was_flood': wasFloodDiscovery,
|
||||
'path_bytes': pathBytes,
|
||||
'success_count': successCount,
|
||||
'failure_count': failureCount,
|
||||
'route_weight': routeWeight,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -36,12 +39,15 @@ class PathRecord {
|
|||
return PathRecord(
|
||||
hopCount: json['hop_count'] as int,
|
||||
tripTimeMs: json['trip_time_ms'] as int,
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
timestamp: json['timestamp'] != null
|
||||
? DateTime.parse(json['timestamp'] as String)
|
||||
: null,
|
||||
wasFloodDiscovery: json['was_flood'] as bool,
|
||||
pathBytes:
|
||||
(json['path_bytes'] as List?)?.map((b) => b as int).toList() ?? [],
|
||||
successCount: json['success_count'] as int? ?? 0,
|
||||
failureCount: json['failure_count'] as int? ?? 0,
|
||||
routeWeight: (json['route_weight'] as num?)?.toDouble() ?? 1.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'contact.dart';
|
||||
|
||||
const int recentAttemptDiversityWindow = 2;
|
||||
|
||||
class PathSelection {
|
||||
final List<int> pathBytes;
|
||||
final int hopCount;
|
||||
|
|
@ -9,3 +15,38 @@ class PathSelection {
|
|||
required this.useFlood,
|
||||
});
|
||||
}
|
||||
|
||||
PathSelection resolvePathSelection(
|
||||
Contact contact, {
|
||||
PathSelection? selection,
|
||||
bool forceFlood = false,
|
||||
}) {
|
||||
if (contact.pathOverride != null) {
|
||||
if (contact.pathOverride! < 0) {
|
||||
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
|
||||
}
|
||||
return PathSelection(
|
||||
pathBytes: contact.pathOverrideBytes ?? Uint8List(0),
|
||||
hopCount: contact.pathOverride!,
|
||||
useFlood: false,
|
||||
);
|
||||
}
|
||||
|
||||
if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) {
|
||||
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
|
||||
}
|
||||
|
||||
if (selection != null && selection.pathBytes.isNotEmpty) {
|
||||
return PathSelection(
|
||||
pathBytes: selection.pathBytes,
|
||||
hopCount: selection.hopCount,
|
||||
useFlood: false,
|
||||
);
|
||||
}
|
||||
|
||||
return PathSelection(
|
||||
pathBytes: contact.path,
|
||||
hopCount: contact.pathLength,
|
||||
useFlood: false,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -310,6 +310,118 @@ class AppSettingsScreen extends StatelessWidget {
|
|||
);
|
||||
},
|
||||
),
|
||||
if (settingsService.settings.autoRouteRotationEnabled) ...[
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_maxRouteWeight),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(context.l10n.appSettings_maxRouteWeightSubtitle),
|
||||
Slider(
|
||||
value: settingsService.settings.maxRouteWeight,
|
||||
min: 1,
|
||||
max: 10,
|
||||
divisions: 9,
|
||||
label: settingsService.settings.maxRouteWeight
|
||||
.round()
|
||||
.toString(),
|
||||
onChanged: (value) =>
|
||||
settingsService.setMaxRouteWeight(value),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_initialRouteWeight),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(context.l10n.appSettings_initialRouteWeightSubtitle),
|
||||
Slider(
|
||||
value: settingsService.settings.initialRouteWeight,
|
||||
min: 0.5,
|
||||
max: 5.0,
|
||||
divisions: 9,
|
||||
label: settingsService.settings.initialRouteWeight
|
||||
.toStringAsFixed(1),
|
||||
onChanged: (value) =>
|
||||
settingsService.setInitialRouteWeight(value),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_routeWeightSuccessIncrement),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context
|
||||
.l10n
|
||||
.appSettings_routeWeightSuccessIncrementSubtitle,
|
||||
),
|
||||
Slider(
|
||||
value: settingsService.settings.routeWeightSuccessIncrement,
|
||||
min: 0.1,
|
||||
max: 2.0,
|
||||
divisions: 19,
|
||||
label: settingsService.settings.routeWeightSuccessIncrement
|
||||
.toStringAsFixed(1),
|
||||
onChanged: (value) =>
|
||||
settingsService.setRouteWeightSuccessIncrement(value),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_routeWeightFailureDecrement),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context
|
||||
.l10n
|
||||
.appSettings_routeWeightFailureDecrementSubtitle,
|
||||
),
|
||||
Slider(
|
||||
value: settingsService.settings.routeWeightFailureDecrement,
|
||||
min: 0.1,
|
||||
max: 2.0,
|
||||
divisions: 19,
|
||||
label: settingsService.settings.routeWeightFailureDecrement
|
||||
.toStringAsFixed(1),
|
||||
onChanged: (value) =>
|
||||
settingsService.setRouteWeightFailureDecrement(value),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_maxMessageRetries),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(context.l10n.appSettings_maxMessageRetriesSubtitle),
|
||||
Slider(
|
||||
value: settingsService.settings.maxMessageRetries
|
||||
.toDouble(),
|
||||
min: 2,
|
||||
max: 10,
|
||||
divisions: 8,
|
||||
label: settingsService.settings.maxMessageRetries
|
||||
.toString(),
|
||||
onChanged: (value) =>
|
||||
settingsService.setMaxMessageRetries(value.toInt()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ import 'dart:math' as math;
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../utils/platform_info.dart';
|
||||
import '../helpers/chat_scroll_controller.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../helpers/link_handler.dart';
|
||||
|
|
@ -311,8 +311,13 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||
],
|
||||
Flexible(
|
||||
child: GestureDetector(
|
||||
onTap: () => _showMessagePathInfo(message),
|
||||
onTap: PlatformInfo.isDesktop
|
||||
? null
|
||||
: () => _showMessagePathInfo(message),
|
||||
onLongPress: () => _showMessageActions(message),
|
||||
onSecondaryTapUp: PlatformInfo.isDesktop
|
||||
? (_) => _showMessageActions(message)
|
||||
: null,
|
||||
child: Container(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.all(4)
|
||||
|
|
@ -430,7 +435,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Linkify(
|
||||
child: LinkHandler.buildLinkifyText(
|
||||
context: context,
|
||||
text: message.text,
|
||||
style: TextStyle(
|
||||
fontSize: bodyFontSize * textScale,
|
||||
|
|
@ -440,15 +446,6 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||
color: Colors.green,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
options: const LinkifyOptions(
|
||||
humanize: false,
|
||||
defaultToHttps: false,
|
||||
),
|
||||
linkifiers: const [UrlLinkifier()],
|
||||
onOpen: (link) => LinkHandler.handleLinkTap(
|
||||
context,
|
||||
link.url,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!enableTracing && isOutgoing) ...[
|
||||
|
|
@ -557,7 +554,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||
],
|
||||
);
|
||||
|
||||
if (!isOutgoing) {
|
||||
if (!isOutgoing && !PlatformInfo.isDesktop) {
|
||||
return _SwipeReplyBubble(
|
||||
maxSwipeOffset: maxSwipeOffset,
|
||||
replySwipeThreshold: replySwipeThreshold,
|
||||
|
|
@ -1112,6 +1109,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||
_setReplyingTo(message);
|
||||
},
|
||||
),
|
||||
if (PlatformInfo.isDesktop)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.route),
|
||||
title: Text(context.l10n.chat_path),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_showMessagePathInfo(message);
|
||||
},
|
||||
),
|
||||
// Can't react to your own messages
|
||||
if (!message.isOutgoing)
|
||||
ListTile(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'dart:typed_data';
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:meshcore_open/storage/channel_message_store.dart';
|
||||
import 'package:meshcore_open/utils/platform_info.dart';
|
||||
import 'package:meshcore_open/widgets/app_bar.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
|
@ -417,78 +418,96 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||
return Card(
|
||||
key: ValueKey('channel_${channel.index}'),
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
minVerticalPadding: 0,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
visualDensity: const VisualDensity(vertical: -2),
|
||||
leading: Stack(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: bgColor,
|
||||
child: Icon(icon, color: iconColor),
|
||||
),
|
||||
if (isCommunityChannel)
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: 14,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.purple,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).cardColor,
|
||||
width: 2,
|
||||
child: GestureDetector(
|
||||
onSecondaryTapUp: PlatformInfo.isDesktop
|
||||
? (_) => _showChannelActions(
|
||||
context,
|
||||
connector,
|
||||
channelMessageStore,
|
||||
channel,
|
||||
)
|
||||
: null,
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
minVerticalPadding: 0,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
visualDensity: const VisualDensity(vertical: -2),
|
||||
leading: Stack(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: bgColor,
|
||||
child: Icon(icon, color: iconColor),
|
||||
),
|
||||
if (isCommunityChannel)
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: 14,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.purple,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).cardColor,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.people,
|
||||
size: 8,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.people, size: 8, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Text(
|
||||
channel.name.isEmpty
|
||||
? context.l10n.channels_channelIndex(channel.index)
|
||||
: channel.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(subtitle, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (unreadCount > 0) ...[
|
||||
UnreadBadge(count: unreadCount),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
if (showDragHandle && dragIndex != null)
|
||||
ReorderableDelayedDragStartListener(
|
||||
index: dragIndex,
|
||||
child: Icon(
|
||||
Icons.drag_handle,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
title: Text(
|
||||
channel.name.isEmpty
|
||||
? context.l10n.channels_channelIndex(channel.index)
|
||||
: channel.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (unreadCount > 0) ...[
|
||||
UnreadBadge(count: unreadCount),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
if (showDragHandle && dragIndex != null)
|
||||
ReorderableDelayedDragStartListener(
|
||||
index: dragIndex,
|
||||
child: Icon(
|
||||
Icons.drag_handle,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () async {
|
||||
connector.markChannelRead(channel.index);
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
if (context.mounted) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ChannelChatScreen(channel: channel),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onLongPress: () => _showChannelActions(
|
||||
context,
|
||||
connector,
|
||||
channelMessageStore,
|
||||
channel,
|
||||
],
|
||||
),
|
||||
onTap: () async {
|
||||
connector.markChannelRead(channel.index);
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
if (context.mounted) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ChannelChatScreen(channel: channel),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onLongPress: () => _showChannelActions(
|
||||
context,
|
||||
connector,
|
||||
channelMessageStore,
|
||||
channel,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:meshcore_open/screens/path_trace_map.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../utils/platform_info.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
|
|
@ -16,6 +17,7 @@ import '../helpers/reaction_helper.dart';
|
|||
import '../widgets/message_status_icon.dart';
|
||||
import '../helpers/chat_scroll_controller.dart';
|
||||
import '../helpers/link_handler.dart';
|
||||
import '../helpers/path_helper.dart';
|
||||
import '../helpers/utf8_length_limiter.dart';
|
||||
import '../models/channel_message.dart';
|
||||
import '../models/contact.dart';
|
||||
|
|
@ -362,6 +364,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
textScale: textScale,
|
||||
onTap: () => _openMessagePath(message, contact),
|
||||
onLongPress: () => _showMessageActions(message, contact),
|
||||
onRetryReaction: (msg, emoji) =>
|
||||
_sendReaction(msg, contact, emoji),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -820,7 +824,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
String _formatRelativeTime(DateTime time) {
|
||||
String _formatRelativeTime(DateTime? time) {
|
||||
if (time == null) return '—';
|
||||
final diff = DateTime.now().difference(time);
|
||||
if (diff.inSeconds < 60) return context.l10n.time_justNow;
|
||||
if (diff.inMinutes < 60) {
|
||||
|
|
@ -841,15 +846,31 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
return;
|
||||
}
|
||||
|
||||
final formattedPath = pathBytes
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
||||
.join(',');
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final allContacts = connector.allContacts;
|
||||
|
||||
final formattedPath = PathHelper.formatPathHex(pathBytes);
|
||||
final resolvedNames = PathHelper.resolvePathNames(pathBytes, allContacts);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.chat_fullPath),
|
||||
content: SelectableText(formattedPath),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SelectableText(formattedPath),
|
||||
const SizedBox(height: 8),
|
||||
SelectableText(
|
||||
resolvedNames,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.push(
|
||||
|
|
@ -1127,6 +1148,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
_showEmojiPicker(message, contact);
|
||||
},
|
||||
),
|
||||
if (PlatformInfo.isDesktop)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.route),
|
||||
title: Text(context.l10n.chat_path),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_openMessagePath(message, contact);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.copy),
|
||||
title: Text(context.l10n.common_copy),
|
||||
|
|
@ -1237,6 +1267,7 @@ class _MessageBubble extends StatelessWidget {
|
|||
final bool isRoomServer;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onLongPress;
|
||||
final void Function(Message message, String emoji)? onRetryReaction;
|
||||
final double textScale;
|
||||
|
||||
const _MessageBubble({
|
||||
|
|
@ -1246,6 +1277,7 @@ class _MessageBubble extends StatelessWidget {
|
|||
required this.textScale,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.onRetryReaction,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -1279,8 +1311,11 @@ class _MessageBubble extends StatelessWidget {
|
|||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: onTap,
|
||||
onTap: PlatformInfo.isDesktop ? null : onTap,
|
||||
onLongPress: onLongPress,
|
||||
onSecondaryTapUp: PlatformInfo.isDesktop
|
||||
? (_) => onLongPress?.call()
|
||||
: null,
|
||||
child: Row(
|
||||
mainAxisAlignment: isOutgoing
|
||||
? MainAxisAlignment.end
|
||||
|
|
@ -1397,7 +1432,8 @@ class _MessageBubble extends StatelessWidget {
|
|||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Linkify(
|
||||
child: LinkHandler.buildLinkifyText(
|
||||
context: context,
|
||||
text: messageText,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
|
|
@ -1408,15 +1444,6 @@ class _MessageBubble extends StatelessWidget {
|
|||
decoration: TextDecoration.underline,
|
||||
fontSize: bodyFontSize * textScale,
|
||||
),
|
||||
options: const LinkifyOptions(
|
||||
humanize: false,
|
||||
defaultToHttps: false,
|
||||
),
|
||||
linkifiers: const [UrlLinkifier()],
|
||||
onOpen: (link) => LinkHandler.handleLinkTap(
|
||||
context,
|
||||
link.url,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!enableTracing && isOutgoing) ...[
|
||||
|
|
@ -1606,33 +1633,64 @@ class _MessageBubble extends StatelessWidget {
|
|||
children: message.reactions.entries.map((entry) {
|
||||
final emoji = entry.key;
|
||||
final count = entry.value;
|
||||
final status = message.reactionStatuses[emoji];
|
||||
final isPending =
|
||||
status == MessageStatus.pending || status == MessageStatus.sent;
|
||||
final isFailed = status == MessageStatus.failed;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: colorScheme.outline.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(emoji, style: const TextStyle(fontSize: 16)),
|
||||
if (count > 1) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'$count',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
),
|
||||
return GestureDetector(
|
||||
onTap: isFailed && onRetryReaction != null
|
||||
? () => onRetryReaction!(message, emoji)
|
||||
: null,
|
||||
child: Opacity(
|
||||
opacity: isPending ? 0.5 : 1.0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isFailed
|
||||
? colorScheme.errorContainer
|
||||
: colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isFailed
|
||||
? colorScheme.error
|
||||
: colorScheme.outline.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(emoji, style: const TextStyle(fontSize: 16)),
|
||||
if (count > 1) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'$count',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (isPending) ...[
|
||||
const SizedBox(width: 2),
|
||||
SizedBox(
|
||||
width: 8,
|
||||
height: 8,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1.5,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (isFailed) ...[
|
||||
const SizedBox(width: 2),
|
||||
Icon(Icons.replay, size: 10, color: colorScheme.error),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'package:flutter/services.dart';
|
|||
import 'package:meshcore_open/screens/path_trace_map.dart';
|
||||
import 'package:meshcore_open/services/notification_service.dart';
|
||||
import 'package:meshcore_open/utils/app_logger.dart';
|
||||
import 'package:meshcore_open/utils/platform_info.dart';
|
||||
import 'package:meshcore_open/widgets/app_bar.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
|
|
@ -1439,66 +1440,77 @@ class _ContactTile extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: _getTypeColor(contact.type),
|
||||
child: _buildContactAvatar(contact),
|
||||
),
|
||||
title: Text(contact.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(contact.pathLabel, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
Text(
|
||||
contact.shortPubKeyHex,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Clamp text scaling in trailing section to prevent overflow while
|
||||
// maintaining accessibility. Primary content (title/subtitle) scales normally.
|
||||
trailing: MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
textScaler: TextScaler.linear(
|
||||
MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3),
|
||||
),
|
||||
return GestureDetector(
|
||||
onSecondaryTapUp: PlatformInfo.isDesktop ? (_) => onLongPress() : null,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: _getTypeColor(contact.type),
|
||||
child: _buildContactAvatar(contact),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: 120,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (unreadCount > 0) ...[
|
||||
UnreadBadge(count: unreadCount),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
Text(
|
||||
_formatLastSeen(context, lastSeen),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isFavorite)
|
||||
Icon(Icons.star, size: 14, color: Colors.amber[700]),
|
||||
if (isFavorite && contact.hasLocation)
|
||||
const SizedBox(width: 2),
|
||||
if (contact.hasLocation)
|
||||
Icon(Icons.location_on, size: 14, color: Colors.grey[400]),
|
||||
title: Text(contact.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
contact.pathLabel,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
contact.shortPubKeyHex,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Clamp text scaling in trailing section to prevent overflow while
|
||||
// maintaining accessibility. Primary content (title/subtitle) scales normally.
|
||||
trailing: MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
textScaler: TextScaler.linear(
|
||||
MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3),
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: 120,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (unreadCount > 0) ...[
|
||||
UnreadBadge(count: unreadCount),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
),
|
||||
],
|
||||
Text(
|
||||
_formatLastSeen(context, lastSeen),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isFavorite)
|
||||
Icon(Icons.star, size: 14, color: Colors.amber[700]),
|
||||
if (isFavorite && contact.hasLocation)
|
||||
const SizedBox(width: 2),
|
||||
if (contact.hasLocation)
|
||||
Icon(
|
||||
Icons.location_on,
|
||||
size: 14,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
),
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import '../connector/meshcore_protocol.dart';
|
|||
import '../l10n/l10n.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../utils/contact_search.dart';
|
||||
import '../utils/platform_info.dart';
|
||||
import '../widgets/app_bar.dart';
|
||||
import '../widgets/list_filter_widget.dart';
|
||||
|
||||
|
|
@ -88,7 +89,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||
itemCount: filteredAndSorted.length,
|
||||
itemBuilder: (context, index) {
|
||||
final contact = filteredAndSorted[index];
|
||||
return ListTile(
|
||||
final tile = ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: _getTypeColor(contact.type),
|
||||
child: Icon(
|
||||
|
|
@ -120,6 +121,14 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||
onLongPress: () =>
|
||||
_showContactContextMenu(contact, connector),
|
||||
);
|
||||
if (PlatformInfo.isDesktop) {
|
||||
return GestureDetector(
|
||||
onSecondaryTapUp: (_) =>
|
||||
_showContactContextMenu(contact, connector),
|
||||
child: tile,
|
||||
);
|
||||
}
|
||||
return tile;
|
||||
},
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -617,19 +617,6 @@ class _MapScreenState extends State<MapScreen> {
|
|||
if (r != null) anchorSet.add(LatLng(r.latitude!, r.longitude!));
|
||||
}
|
||||
|
||||
// Fallback: for any last-hop byte with no GPS repeater, average the
|
||||
// positions of contacts with known GPS that share the same last hop.
|
||||
// Those contacts are all adjacent to the same unknown repeater, so their
|
||||
// centroid is a reasonable proxy for its location.
|
||||
for (final byte in lastHopBytes) {
|
||||
if (repeaterByHash.containsKey(byte)) continue;
|
||||
for (final c in withLocation) {
|
||||
if (c.path.isNotEmpty && c.path.last == byte) {
|
||||
anchorSet.add(LatLng(c.latitude!, c.longitude!));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter anchors that are geometrically inconsistent with radio range.
|
||||
// Two anchors more than 2 * maxRange apart cannot both be in direct radio
|
||||
// range of the same node, so isolated outliers are removed.
|
||||
|
|
@ -641,15 +628,12 @@ class _MapScreenState extends State<MapScreen> {
|
|||
|
||||
final LatLng position;
|
||||
if (anchors.length == 1) {
|
||||
// Offset single-anchor guesses so they don't overlap the repeater marker.
|
||||
// Use the contact's public key byte as a deterministic angle seed.
|
||||
const offsetDeg = 0.003; // ~330 m at the equator
|
||||
final angle = (contact.publicKey[1] / 255.0) * 2 * pi;
|
||||
position = LatLng(
|
||||
anchors[0].latitude + offsetDeg * cos(angle),
|
||||
anchors[0].longitude + offsetDeg * sin(angle),
|
||||
// Spread single-anchor guesses around the anchor so they remain visible.
|
||||
position = _offsetGuessedPosition(
|
||||
anchors[0],
|
||||
contact,
|
||||
radiusMeters: 330,
|
||||
);
|
||||
|
||||
if (!_checkLocationPlausibility(
|
||||
position.latitude,
|
||||
position.longitude,
|
||||
|
|
@ -662,7 +646,11 @@ class _MapScreenState extends State<MapScreen> {
|
|||
lat += a.latitude;
|
||||
lon += a.longitude;
|
||||
}
|
||||
position = LatLng(lat / anchors.length, lon / anchors.length);
|
||||
position = _offsetGuessedPosition(
|
||||
LatLng(lat / anchors.length, lon / anchors.length),
|
||||
contact,
|
||||
radiusMeters: anchors.length >= 3 ? 80 : 120,
|
||||
);
|
||||
if (!_checkLocationPlausibility(
|
||||
position.latitude,
|
||||
position.longitude,
|
||||
|
|
@ -682,6 +670,31 @@ class _MapScreenState extends State<MapScreen> {
|
|||
return result;
|
||||
}
|
||||
|
||||
LatLng _offsetGuessedPosition(
|
||||
LatLng anchor,
|
||||
Contact contact, {
|
||||
required double radiusMeters,
|
||||
}) {
|
||||
final seed = _guessSeed(contact.publicKey);
|
||||
final angle = ((seed & 0xFFFF) / 0x10000) * 2 * pi;
|
||||
final latOffsetDeg = (radiusMeters / 111320.0) * cos(angle);
|
||||
final lonScale = max(cos(anchor.latitude * pi / 180.0).abs(), 0.2);
|
||||
final lonOffsetDeg = (radiusMeters / (111320.0 * lonScale)) * sin(angle);
|
||||
return LatLng(
|
||||
anchor.latitude + latOffsetDeg,
|
||||
anchor.longitude + lonOffsetDeg,
|
||||
);
|
||||
}
|
||||
|
||||
int _guessSeed(Uint8List publicKey) {
|
||||
var seed = 0x811C9DC5;
|
||||
for (final byte in publicKey) {
|
||||
seed ^= byte;
|
||||
seed = (seed * 0x01000193) & 0x7FFFFFFF;
|
||||
}
|
||||
return seed;
|
||||
}
|
||||
|
||||
/// Estimates the free-space maximum LoRa range in km from the connected
|
||||
/// device's current radio parameters. Returns null if parameters are unknown.
|
||||
double? _estimateLoRaRangeKm(MeshCoreConnector connector) {
|
||||
|
|
|
|||
|
|
@ -120,6 +120,30 @@ class AppSettingsService extends ChangeNotifier {
|
|||
await updateSettings(_settings.copyWith(autoRouteRotationEnabled: value));
|
||||
}
|
||||
|
||||
Future<void> setMaxRouteWeight(double value) async {
|
||||
await updateSettings(_settings.copyWith(maxRouteWeight: value));
|
||||
}
|
||||
|
||||
Future<void> setInitialRouteWeight(double value) async {
|
||||
await updateSettings(_settings.copyWith(initialRouteWeight: value));
|
||||
}
|
||||
|
||||
Future<void> setRouteWeightSuccessIncrement(double value) async {
|
||||
await updateSettings(
|
||||
_settings.copyWith(routeWeightSuccessIncrement: value),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setRouteWeightFailureDecrement(double value) async {
|
||||
await updateSettings(
|
||||
_settings.copyWith(routeWeightFailureDecrement: value),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setMaxMessageRetries(int value) async {
|
||||
await updateSettings(_settings.copyWith(maxMessageRetries: value));
|
||||
}
|
||||
|
||||
Future<void> setThemeMode(String value) async {
|
||||
await updateSettings(_settings.copyWith(themeMode: value));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,86 +21,74 @@ class _AckHistoryEntry {
|
|||
});
|
||||
}
|
||||
|
||||
class _AckHashMapping {
|
||||
final String messageId;
|
||||
final DateTime timestamp;
|
||||
/// (messageId, timestamp, attemptIndex) — stored per ACK hash for O(1) lookup.
|
||||
typedef AckHashMapping = ({String messageId, DateTime timestamp, int attemptIndex});
|
||||
|
||||
_AckHashMapping({required this.messageId, required this.timestamp});
|
||||
class RetryServiceConfig {
|
||||
final void Function(Contact, String, int, int) sendMessage;
|
||||
final void Function(String, Message) addMessage;
|
||||
final void Function(Message) updateMessage;
|
||||
final Function(Contact)? clearContactPath;
|
||||
final Function(Contact, Uint8List, int)? setContactPath;
|
||||
final int Function(int pathLength, int messageBytes, {String? contactKey})?
|
||||
calculateTimeout;
|
||||
final Uint8List? Function()? getSelfPublicKey;
|
||||
final String Function(Contact, String)? prepareContactOutboundText;
|
||||
final AppSettingsService? appSettingsService;
|
||||
final AppDebugLogService? debugLogService;
|
||||
final void Function(String, PathSelection, bool, int?)? recordPathResult;
|
||||
final void Function(String, int, int, int)? onDeliveryObserved;
|
||||
final PathSelection? Function(
|
||||
String contactKey,
|
||||
int attemptIndex,
|
||||
int maxRetries,
|
||||
List<PathSelection> recentSelections,
|
||||
)? selectRetryPath;
|
||||
|
||||
const RetryServiceConfig({
|
||||
required this.sendMessage,
|
||||
required this.addMessage,
|
||||
required this.updateMessage,
|
||||
this.clearContactPath,
|
||||
this.setContactPath,
|
||||
this.calculateTimeout,
|
||||
this.getSelfPublicKey,
|
||||
this.prepareContactOutboundText,
|
||||
this.appSettingsService,
|
||||
this.debugLogService,
|
||||
this.recordPathResult,
|
||||
this.onDeliveryObserved,
|
||||
this.selectRetryPath,
|
||||
});
|
||||
}
|
||||
|
||||
class MessageRetryService extends ChangeNotifier {
|
||||
static const int maxRetries = 5;
|
||||
static const int maxAckHistorySize = 100;
|
||||
int _maxRetries = 5;
|
||||
int get maxRetries => _maxRetries;
|
||||
|
||||
final Map<String, Timer> _timeoutTimers = {};
|
||||
final Map<String, Message> _pendingMessages = {};
|
||||
final Map<String, Contact> _pendingContacts = {};
|
||||
final Map<String, PathSelection> _pendingPathSelections = {};
|
||||
final Map<String, _AckHashMapping> _ackHashToMessageId =
|
||||
{}; // ackHashHex → messageId + timestamp for O(1) lookup
|
||||
final Map<String, List<Uint8List>> _expectedAckHashes =
|
||||
{}; // Track all expected ACKs for retries (for history)
|
||||
final List<_AckHistoryEntry> _ackHistory =
|
||||
[]; // Rolling buffer of recent ACK hashes
|
||||
final Map<String, List<String>> _pendingMessageQueuePerContact =
|
||||
{}; // contactPubKeyHex → FIFO queue of messageIds (DEPRECATED - will be removed)
|
||||
final Map<String, List<String>> _sendQueue =
|
||||
{}; // contactPubKeyHex → ordered list of messageIds awaiting send
|
||||
final Set<String> _activeMessages =
|
||||
{}; // messageIds currently in-flight (sent/retrying)
|
||||
final Set<String> _resolvedMessages =
|
||||
{}; // messageIds already resolved (prevents double _onMessageResolved)
|
||||
final Map<String, String> _expectedHashToMessageId =
|
||||
{}; // expectedAckHashHex → messageId (for matching RESP_CODE_SENT by hash)
|
||||
final Map<String, List<PathSelection>> _attemptPathHistory = {};
|
||||
final Map<String, AckHashMapping> _ackHashToMessageId = {};
|
||||
final Map<String, List<Uint8List>> _expectedAckHashes = {};
|
||||
final List<_AckHistoryEntry> _ackHistory = [];
|
||||
final Map<String, List<String>> _sendQueue = {};
|
||||
final Set<String> _activeMessages = {};
|
||||
final Set<String> _resolvedMessages = {};
|
||||
final Map<String, String> _expectedHashToMessageId = {};
|
||||
|
||||
Function(Contact, String, int, int)? _sendMessageCallback;
|
||||
Function(String, Message)? _addMessageCallback;
|
||||
Function(Message)? _updateMessageCallback;
|
||||
Function(Contact)? _clearContactPathCallback;
|
||||
Function(Contact, Uint8List, int)? _setContactPathCallback;
|
||||
Function(int, int, {String? contactKey})? _calculateTimeoutCallback;
|
||||
Uint8List? Function()? _getSelfPublicKeyCallback;
|
||||
String Function(Contact, String)? _prepareContactOutboundTextCallback;
|
||||
AppSettingsService? _appSettingsService;
|
||||
AppDebugLogService? _debugLogService;
|
||||
Function(String, PathSelection, bool, int?)? _recordPathResultCallback;
|
||||
Function(String, int, int, int)? _onDeliveryObservedCallback;
|
||||
RetryServiceConfig? _config;
|
||||
|
||||
MessageRetryService();
|
||||
|
||||
void initialize({
|
||||
required Function(Contact, String, int, int) sendMessageCallback,
|
||||
required Function(String, Message) addMessageCallback,
|
||||
required Function(Message) updateMessageCallback,
|
||||
Function(Contact)? clearContactPathCallback,
|
||||
Function(Contact, Uint8List, int)? setContactPathCallback,
|
||||
Function(int pathLength, int messageBytes, {String? contactKey})?
|
||||
calculateTimeoutCallback,
|
||||
Uint8List? Function()? getSelfPublicKeyCallback,
|
||||
String Function(Contact, String)? prepareContactOutboundTextCallback,
|
||||
AppSettingsService? appSettingsService,
|
||||
AppDebugLogService? debugLogService,
|
||||
Function(String, PathSelection, bool, int?)? recordPathResultCallback,
|
||||
Function(
|
||||
String contactKey,
|
||||
int pathLength,
|
||||
int messageBytes,
|
||||
int tripTimeMs,
|
||||
)?
|
||||
onDeliveryObservedCallback,
|
||||
}) {
|
||||
_sendMessageCallback = sendMessageCallback;
|
||||
_addMessageCallback = addMessageCallback;
|
||||
_updateMessageCallback = updateMessageCallback;
|
||||
_clearContactPathCallback = clearContactPathCallback;
|
||||
_setContactPathCallback = setContactPathCallback;
|
||||
_calculateTimeoutCallback = calculateTimeoutCallback;
|
||||
_getSelfPublicKeyCallback = getSelfPublicKeyCallback;
|
||||
_prepareContactOutboundTextCallback = prepareContactOutboundTextCallback;
|
||||
_appSettingsService = appSettingsService;
|
||||
_debugLogService = debugLogService;
|
||||
_recordPathResultCallback = recordPathResultCallback;
|
||||
_onDeliveryObservedCallback = onDeliveryObservedCallback;
|
||||
void initialize(RetryServiceConfig config) {
|
||||
_config = config;
|
||||
}
|
||||
|
||||
void setMaxRetries(int value) {
|
||||
_maxRetries = value.clamp(2, 10);
|
||||
}
|
||||
|
||||
/// Compute expected ACK hash using same algorithm as firmware:
|
||||
|
|
@ -139,17 +127,14 @@ class MessageRetryService extends ChangeNotifier {
|
|||
Future<void> sendMessageWithRetry({
|
||||
required Contact contact,
|
||||
required String text,
|
||||
PathSelection? pathSelection,
|
||||
Uint8List? pathBytes,
|
||||
int? pathLength,
|
||||
}) async {
|
||||
final messageId = const Uuid().v4();
|
||||
final useFlood = pathSelection?.useFlood ?? false;
|
||||
final messagePathBytes =
|
||||
pathBytes ?? _resolveMessagePathBytes(contact, useFlood, pathSelection);
|
||||
final resolved = resolvePathSelection(contact);
|
||||
final messagePathBytes = pathBytes ?? Uint8List.fromList(resolved.pathBytes);
|
||||
final messagePathLength =
|
||||
pathLength ??
|
||||
_resolveMessagePathLength(contact, useFlood, pathSelection);
|
||||
pathLength ?? (resolved.useFlood ? -1 : resolved.hopCount);
|
||||
final message = Message(
|
||||
senderKey: contact.publicKey,
|
||||
text: text,
|
||||
|
|
@ -164,13 +149,8 @@ class MessageRetryService extends ChangeNotifier {
|
|||
|
||||
_pendingMessages[messageId] = message;
|
||||
_pendingContacts[messageId] = contact;
|
||||
if (pathSelection != null) {
|
||||
_pendingPathSelections[messageId] = pathSelection;
|
||||
}
|
||||
|
||||
if (_addMessageCallback != null) {
|
||||
_addMessageCallback!(contact.publicKeyHex, message);
|
||||
}
|
||||
_config?.addMessage(contact.publicKeyHex, message);
|
||||
|
||||
// Queue per contact — only one message in-flight at a time to avoid
|
||||
// overflowing the firmware's 8-entry expected_ack_table.
|
||||
|
|
@ -200,13 +180,12 @@ class MessageRetryService extends ChangeNotifier {
|
|||
if (msg != null) {
|
||||
final failed = msg.copyWith(status: MessageStatus.failed);
|
||||
_pendingMessages[messageId] = failed;
|
||||
_updateMessageCallback?.call(failed);
|
||||
_config?.updateMessage(failed);
|
||||
}
|
||||
_onMessageResolved(messageId, contactKey);
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Message was cancelled/cleaned up while queued — try next
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -217,33 +196,87 @@ class MessageRetryService extends ChangeNotifier {
|
|||
_sendNextForContact(contactKey);
|
||||
}
|
||||
|
||||
PathSelection? _selectPathForAttempt(Message message, Contact contact) {
|
||||
final config = _config;
|
||||
if (config == null) return null;
|
||||
final autoRotationEnabled =
|
||||
config.appSettingsService?.settings.autoRouteRotationEnabled == true;
|
||||
if (!autoRotationEnabled ||
|
||||
contact.pathOverride != null ||
|
||||
config.selectRetryPath == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final recentSelections = List<PathSelection>.from(
|
||||
_attemptPathHistory[message.messageId] ?? const <PathSelection>[],
|
||||
);
|
||||
return config.selectRetryPath!(
|
||||
contact.publicKeyHex,
|
||||
message.retryCount,
|
||||
maxRetries,
|
||||
recentSelections,
|
||||
);
|
||||
}
|
||||
|
||||
void _recordAttemptPathHistory(String messageId, PathSelection selection) {
|
||||
if (selection.useFlood) return;
|
||||
final history = _attemptPathHistory.putIfAbsent(messageId, () => []);
|
||||
history.add(selection);
|
||||
if (history.length > recentAttemptDiversityWindow) {
|
||||
history.removeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _attemptSend(String messageId) async {
|
||||
final message = _pendingMessages[messageId];
|
||||
final contact = _pendingContacts[messageId];
|
||||
final config = _config;
|
||||
|
||||
if (message == null || contact == null) return;
|
||||
if (message == null || contact == null || config == null) return;
|
||||
|
||||
final currentSelection = _selectPathForAttempt(message, contact);
|
||||
|
||||
if (currentSelection != null) {
|
||||
final updatedMessage = message.copyWith(
|
||||
pathLength: currentSelection.useFlood ? -1 : currentSelection.hopCount,
|
||||
pathBytes: currentSelection.useFlood
|
||||
? Uint8List(0)
|
||||
: Uint8List.fromList(currentSelection.pathBytes),
|
||||
);
|
||||
_pendingMessages[messageId] = updatedMessage;
|
||||
} else if (message.retryCount > 0) {
|
||||
// No schedule entry for this retry — re-resolve path from current contact
|
||||
// state so user's path override changes are picked up between retries.
|
||||
final resolved = resolvePathSelection(contact);
|
||||
final updatedMessage = message.copyWith(
|
||||
pathLength: resolved.useFlood ? -1 : resolved.hopCount,
|
||||
pathBytes: Uint8List.fromList(resolved.pathBytes),
|
||||
);
|
||||
_pendingMessages[messageId] = updatedMessage;
|
||||
}
|
||||
|
||||
// Re-read after potential schedule update
|
||||
final effectiveMessage = _pendingMessages[messageId] ?? message;
|
||||
|
||||
// Sync path settings with device before sending
|
||||
// Use the path that was captured when the message was first sent
|
||||
if (_setContactPathCallback != null && _clearContactPathCallback != null) {
|
||||
if (message.pathLength != null && message.pathLength! < 0) {
|
||||
debugPrint(
|
||||
'Setting flood mode for retry attempt ${message.retryCount}',
|
||||
);
|
||||
await _clearContactPathCallback!(contact);
|
||||
} else if (message.pathLength != null && message.pathLength! >= 0) {
|
||||
final pathStr = message.pathBytes.isEmpty
|
||||
? 'direct'
|
||||
: message.pathBytes
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join(',');
|
||||
debugPrint(
|
||||
'Setting path [$pathStr] (${message.pathLength} hops) for retry attempt ${message.retryCount}',
|
||||
);
|
||||
await _setContactPathCallback!(
|
||||
if (config.setContactPath != null && config.clearContactPath != null) {
|
||||
final bool useFlood = currentSelection != null
|
||||
? currentSelection.useFlood
|
||||
: (effectiveMessage.pathLength != null && effectiveMessage.pathLength! < 0);
|
||||
final List<int> pathBytes = currentSelection != null
|
||||
? currentSelection.pathBytes
|
||||
: effectiveMessage.pathBytes;
|
||||
final int hopCount = currentSelection != null
|
||||
? currentSelection.hopCount
|
||||
: (effectiveMessage.pathLength ?? 0);
|
||||
|
||||
if (useFlood) {
|
||||
await config.clearContactPath!(contact);
|
||||
} else if (effectiveMessage.pathLength != null) {
|
||||
await config.setContactPath!(
|
||||
contact,
|
||||
message.pathBytes,
|
||||
message.pathLength!,
|
||||
Uint8List.fromList(pathBytes),
|
||||
hopCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -257,8 +290,6 @@ class MessageRetryService extends ChangeNotifier {
|
|||
);
|
||||
return;
|
||||
}
|
||||
// If the message was retried by a timer during our await, the retryCount
|
||||
// will have advanced. Only proceed if it still matches the attempt we started.
|
||||
if (currentMessage.retryCount != message.retryCount) {
|
||||
debugPrint(
|
||||
'_attemptSend: message $messageId retryCount changed during path sync, aborting',
|
||||
|
|
@ -266,15 +297,19 @@ class MessageRetryService extends ChangeNotifier {
|
|||
return;
|
||||
}
|
||||
|
||||
final attempt = message.retryCount.clamp(0, 3);
|
||||
if (currentSelection != null) {
|
||||
_recordAttemptPathHistory(messageId, currentSelection);
|
||||
}
|
||||
|
||||
final attempt = message.retryCount;
|
||||
final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
// Compute expected ACK hash that device will return in RESP_CODE_SENT
|
||||
// IMPORTANT: Use the transformed text (with SMAZ encoding if enabled) to match device's hash
|
||||
final selfPubKey = _getSelfPublicKeyCallback?.call();
|
||||
final selfPubKey = config.getSelfPublicKey?.call();
|
||||
if (selfPubKey != null) {
|
||||
final outboundText =
|
||||
_prepareContactOutboundTextCallback?.call(contact, message.text) ??
|
||||
config.prepareContactOutboundText?.call(contact, message.text) ??
|
||||
message.text;
|
||||
final expectedHash = MessageRetryService.computeExpectedAckHash(
|
||||
timestampSeconds,
|
||||
|
|
@ -290,43 +325,24 @@ class MessageRetryService extends ChangeNotifier {
|
|||
final shortText = message.text.length > 20
|
||||
? '${message.text.substring(0, 20)}...'
|
||||
: message.text;
|
||||
_debugLogService?.info(
|
||||
config.debugLogService?.info(
|
||||
'Sent "$shortText" to ${contact.name} → expect ACK hash $expectedHashHex (attempt $attempt)',
|
||||
tag: 'AckHash',
|
||||
);
|
||||
debugPrint(
|
||||
'Computed expected ACK hash $expectedHashHex for message $messageId',
|
||||
);
|
||||
}
|
||||
|
||||
// DEPRECATED: Old queue-based matching (kept for fallback)
|
||||
_pendingMessageQueuePerContact[contact.publicKeyHex] ??= [];
|
||||
_pendingMessageQueuePerContact[contact.publicKeyHex]!.add(messageId);
|
||||
|
||||
if (_sendMessageCallback != null) {
|
||||
_sendMessageCallback!(contact, message.text, attempt, timestampSeconds);
|
||||
} else {
|
||||
// No send callback — message would be stuck forever. Fail it immediately.
|
||||
debugPrint(
|
||||
'_attemptSend: no sendMessageCallback, failing message $messageId',
|
||||
);
|
||||
final failedMessage = message.copyWith(status: MessageStatus.failed);
|
||||
_pendingMessages[messageId] = failedMessage;
|
||||
_updateMessageCallback?.call(failedMessage);
|
||||
_onMessageResolved(messageId, contact.publicKeyHex);
|
||||
}
|
||||
config.sendMessage(contact, message.text, attempt, timestampSeconds);
|
||||
}
|
||||
|
||||
bool updateMessageFromSent(
|
||||
Uint8List ackHash,
|
||||
int timeoutMs, {
|
||||
bool allowQueueFallback = true,
|
||||
}) {
|
||||
bool updateMessageFromSent(Uint8List ackHash, int timeoutMs) {
|
||||
final config = _config;
|
||||
if (config == null) return false;
|
||||
|
||||
final ackHashHex = ackHash
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
|
||||
// NEW: Try hash-based matching first (fixes LoRa message drops causing mismatches)
|
||||
// Try hash-based matching (fixes LoRa message drops causing mismatches)
|
||||
String? messageId = _expectedHashToMessageId.remove(ackHashHex);
|
||||
Contact? contact;
|
||||
|
||||
|
|
@ -338,89 +354,31 @@ class MessageRetryService extends ChangeNotifier {
|
|||
final shortText = message.text.length > 20
|
||||
? '${message.text.substring(0, 20)}...'
|
||||
: message.text;
|
||||
_debugLogService?.info(
|
||||
config.debugLogService?.info(
|
||||
'RESP_CODE_SENT received: ACK hash $ackHashHex ✓ matched "$shortText" to ${contact.name}',
|
||||
tag: 'AckHash',
|
||||
);
|
||||
debugPrint(
|
||||
'Hash-based match: ACK hash $ackHashHex → message $messageId ✓',
|
||||
);
|
||||
|
||||
// Remove from old queue since we matched
|
||||
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId);
|
||||
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
|
||||
false) {
|
||||
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
|
||||
}
|
||||
} else {
|
||||
_debugLogService?.warn(
|
||||
config.debugLogService?.warn(
|
||||
'RESP_CODE_SENT: ACK hash $ackHashHex matched but message no longer pending',
|
||||
tag: 'AckHash',
|
||||
);
|
||||
debugPrint('Hash matched $messageId but message no longer pending');
|
||||
messageId = null;
|
||||
contact = null;
|
||||
}
|
||||
}
|
||||
|
||||
// FALLBACK: Old queue-based matching (for messages sent before hash computation was added)
|
||||
// Only match within a single contact's queue to avoid cross-contact mismatches.
|
||||
if (messageId == null && allowQueueFallback) {
|
||||
_debugLogService?.warn(
|
||||
'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue',
|
||||
tag: 'AckHash',
|
||||
);
|
||||
debugPrint(
|
||||
'Hash-based match failed for $ackHashHex, falling back to queue-based matching',
|
||||
);
|
||||
|
||||
// Search all contact queues so concurrent chats don't miss matches.
|
||||
final queuesToSearch = _pendingMessageQueuePerContact;
|
||||
|
||||
for (var entry in queuesToSearch.entries) {
|
||||
final contactKey = entry.key;
|
||||
final queue = entry.value;
|
||||
|
||||
// Drain stale entries until we find a valid one or exhaust the queue.
|
||||
while (queue.isNotEmpty) {
|
||||
final candidateMessageId = queue.removeAt(0);
|
||||
if (_pendingMessages.containsKey(candidateMessageId)) {
|
||||
messageId = candidateMessageId;
|
||||
contact = _pendingContacts[candidateMessageId];
|
||||
debugPrint(
|
||||
'Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey',
|
||||
);
|
||||
break;
|
||||
}
|
||||
debugPrint('Dequeued stale message $candidateMessageId - skipping');
|
||||
}
|
||||
if (messageId != null) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (messageId == null || contact == null) {
|
||||
debugPrint('No pending message found for ACK hash: $ackHashHex');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Store the mapping for future lookups (e.g., when ACK arrives)
|
||||
// Keep timestamp so we can clean up old mappings later
|
||||
_ackHashToMessageId[ackHashHex] = _AckHashMapping(
|
||||
final message = _pendingMessages[messageId]!;
|
||||
_ackHashToMessageId[ackHashHex] = (
|
||||
messageId: messageId,
|
||||
timestamp: DateTime.now(),
|
||||
attemptIndex: message.retryCount,
|
||||
);
|
||||
debugPrint('Mapped ACK hash $ackHashHex to message $messageId');
|
||||
|
||||
final message = _pendingMessages[messageId];
|
||||
final selection = _pendingPathSelections[messageId];
|
||||
|
||||
if (message == null) {
|
||||
debugPrint(
|
||||
'Message $messageId no longer pending for ACK hash: $ackHashHex',
|
||||
);
|
||||
_ackHashToMessageId.remove(ackHashHex);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add this ACK hash to the list of expected ACKs for this message (for history)
|
||||
_expectedAckHashes[messageId] ??= [];
|
||||
|
|
@ -428,37 +386,20 @@ class MessageRetryService extends ChangeNotifier {
|
|||
(hash) => listEquals(hash, ackHash),
|
||||
)) {
|
||||
_expectedAckHashes[messageId]!.add(Uint8List.fromList(ackHash));
|
||||
debugPrint(
|
||||
'Added ACK hash $ackHashHex to message $messageId (total: ${_expectedAckHashes[messageId]!.length})',
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate timeout: prefer ML prediction, then device-provided, then physics fallback
|
||||
int pathLengthValue;
|
||||
if (selection != null) {
|
||||
pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
|
||||
if (pathLengthValue < 0) pathLengthValue = contact.pathLength;
|
||||
} else if (message.pathLength != null) {
|
||||
pathLengthValue = message.pathLength!;
|
||||
} else {
|
||||
pathLengthValue = contact.pathLength;
|
||||
}
|
||||
final pathLengthValue = message.pathLength ?? contact.pathLength;
|
||||
|
||||
int actualTimeout = timeoutMs;
|
||||
if (_calculateTimeoutCallback != null) {
|
||||
final calculated = _calculateTimeoutCallback!(
|
||||
if (config.calculateTimeout != null) {
|
||||
final calculated = config.calculateTimeout!(
|
||||
pathLengthValue,
|
||||
message.text.length,
|
||||
contactKey: contact.publicKeyHex,
|
||||
);
|
||||
// calculateTimeout tries ML first, falls back to physics.
|
||||
// Use calculated value if device didn't provide one, or if ML
|
||||
// produced a tighter prediction than the device's estimate.
|
||||
if (timeoutMs <= 0 || calculated < timeoutMs) {
|
||||
actualTimeout = calculated;
|
||||
debugPrint(
|
||||
'Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -470,18 +411,26 @@ class MessageRetryService extends ChangeNotifier {
|
|||
);
|
||||
|
||||
_pendingMessages[messageId] = updatedMessage;
|
||||
|
||||
if (_updateMessageCallback != null) {
|
||||
_updateMessageCallback!(updatedMessage);
|
||||
}
|
||||
config.updateMessage(updatedMessage);
|
||||
|
||||
_startTimeoutTimer(messageId, actualTimeout);
|
||||
debugPrint('Updated message $messageId with ACK hash: $ackHashHex');
|
||||
return true;
|
||||
}
|
||||
|
||||
bool get hasPendingMessages => _pendingMessages.isNotEmpty;
|
||||
|
||||
/// Update the stored contact snapshot for all pending messages to this contact.
|
||||
/// Call this when the contact's pathOverride changes so retries use the new path.
|
||||
void updatePendingContact(Contact contact) {
|
||||
final keys = _pendingContacts.entries
|
||||
.where((e) => e.value.publicKeyHex == contact.publicKeyHex)
|
||||
.map((e) => e.key)
|
||||
.toList();
|
||||
for (final key in keys) {
|
||||
_pendingContacts[key] = contact;
|
||||
}
|
||||
}
|
||||
|
||||
void _startTimeoutTimer(String messageId, int timeoutMs) {
|
||||
_timeoutTimers[messageId]?.cancel();
|
||||
_timeoutTimers[messageId] = Timer(Duration(milliseconds: timeoutMs), () {
|
||||
|
|
@ -489,10 +438,24 @@ class MessageRetryService extends ChangeNotifier {
|
|||
});
|
||||
}
|
||||
|
||||
void _cleanupMessage(String messageId) {
|
||||
_moveAckHashesToHistory(messageId);
|
||||
_ackHashToMessageId.removeWhere(
|
||||
(_, mapping) => mapping.messageId == messageId,
|
||||
);
|
||||
_expectedHashToMessageId.removeWhere((_, msgId) => msgId == messageId);
|
||||
_pendingMessages.remove(messageId);
|
||||
_pendingContacts.remove(messageId);
|
||||
_attemptPathHistory.remove(messageId);
|
||||
_timeoutTimers.remove(messageId);
|
||||
_resolvedMessages.remove(messageId);
|
||||
}
|
||||
|
||||
void _handleTimeout(String messageId) {
|
||||
final message = _pendingMessages[messageId];
|
||||
final contact = _pendingContacts[messageId];
|
||||
final selection = _pendingPathSelections[messageId];
|
||||
final config = _config;
|
||||
final selection = message != null ? _selectionFromMessage(message) : null;
|
||||
|
||||
if (message == null || contact == null) {
|
||||
debugPrint(
|
||||
|
|
@ -504,44 +467,40 @@ class MessageRetryService extends ChangeNotifier {
|
|||
final shortText = message.text.length > 20
|
||||
? '${message.text.substring(0, 20)}...'
|
||||
: message.text;
|
||||
_debugLogService?.warn(
|
||||
config?.debugLogService?.warn(
|
||||
'Timeout: No ACK received for "$shortText" to ${contact.name} (attempt ${message.retryCount}) → retrying',
|
||||
tag: 'AckHash',
|
||||
);
|
||||
debugPrint(
|
||||
'Timeout for message $messageId (retry ${message.retryCount}/${maxRetries - 1})',
|
||||
);
|
||||
|
||||
if (message.retryCount < maxRetries - 1) {
|
||||
final backoffMs = 1000 * (1 << message.retryCount);
|
||||
|
||||
if (selection != null) {
|
||||
_recordPathResultFromMessage(
|
||||
contact.publicKeyHex,
|
||||
message,
|
||||
selection,
|
||||
false,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
final updatedMessage = message.copyWith(
|
||||
retryCount: message.retryCount + 1,
|
||||
status: MessageStatus.pending,
|
||||
// Keep expectedAckHash - it will be updated when the new attempt is sent
|
||||
);
|
||||
|
||||
_pendingMessages[messageId] = updatedMessage;
|
||||
config?.updateMessage(updatedMessage);
|
||||
|
||||
if (_updateMessageCallback != null) {
|
||||
_updateMessageCallback!(updatedMessage);
|
||||
}
|
||||
|
||||
_debugLogService?.info(
|
||||
config?.debugLogService?.info(
|
||||
'Scheduling retry for "$shortText" to ${contact.name} after ${backoffMs}ms backoff',
|
||||
tag: 'AckHash',
|
||||
);
|
||||
debugPrint('Scheduling retry after ${backoffMs}ms');
|
||||
|
||||
// Store the backoff timer so it can be canceled if new RESP_CODE_SENT arrives
|
||||
_timeoutTimers[messageId] = Timer(Duration(milliseconds: backoffMs), () {
|
||||
// Double-check message is still pending before retry
|
||||
if (_pendingMessages.containsKey(messageId)) {
|
||||
_attemptSend(messageId);
|
||||
} else {
|
||||
debugPrint(
|
||||
'Retry cancelled: message $messageId was delivered while waiting',
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
|
|
@ -549,10 +508,9 @@ class MessageRetryService extends ChangeNotifier {
|
|||
final failedMessage = message.copyWith(status: MessageStatus.failed);
|
||||
_pendingMessages[messageId] = failedMessage;
|
||||
|
||||
// Check if we should clear the path on max retry
|
||||
if (_appSettingsService?.settings.clearPathOnMaxRetry == true &&
|
||||
_clearContactPathCallback != null) {
|
||||
_clearContactPathCallback!(contact);
|
||||
if (config?.appSettingsService?.settings.clearPathOnMaxRetry == true &&
|
||||
config?.clearContactPath != null) {
|
||||
config!.clearContactPath!(contact);
|
||||
}
|
||||
|
||||
_recordPathResultFromMessage(
|
||||
|
|
@ -563,34 +521,16 @@ class MessageRetryService extends ChangeNotifier {
|
|||
null,
|
||||
);
|
||||
|
||||
if (_updateMessageCallback != null) {
|
||||
_updateMessageCallback!(failedMessage);
|
||||
}
|
||||
config?.updateMessage(failedMessage);
|
||||
|
||||
notifyListeners();
|
||||
|
||||
// Message is done retrying — send next queued message for this contact
|
||||
_onMessageResolved(messageId, contact.publicKeyHex);
|
||||
|
||||
// Keep message in pending maps for 30s grace period so late ACKs
|
||||
// can still match and update the message to delivered.
|
||||
_timeoutTimers[messageId] = Timer(const Duration(seconds: 30), () {
|
||||
_moveAckHashesToHistory(messageId);
|
||||
// Clean up ALL hash mappings for this message
|
||||
_ackHashToMessageId.removeWhere(
|
||||
(_, mapping) => mapping.messageId == messageId,
|
||||
);
|
||||
_expectedHashToMessageId.removeWhere((_, msgId) => msgId == messageId);
|
||||
_pendingMessages.remove(messageId);
|
||||
_pendingContacts.remove(messageId);
|
||||
_pendingPathSelections.remove(messageId);
|
||||
_timeoutTimers.remove(messageId);
|
||||
_resolvedMessages.remove(messageId);
|
||||
final contactKey = contact.publicKeyHex;
|
||||
_pendingMessageQueuePerContact[contactKey]?.remove(messageId);
|
||||
if (_pendingMessageQueuePerContact[contactKey]?.isEmpty ?? false) {
|
||||
_pendingMessageQueuePerContact.remove(contactKey);
|
||||
}
|
||||
_cleanupMessage(messageId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -606,14 +546,9 @@ class MessageRetryService extends ChangeNotifier {
|
|||
),
|
||||
);
|
||||
|
||||
// Trim history to max size (rolling buffer)
|
||||
while (_ackHistory.length > maxAckHistorySize) {
|
||||
_ackHistory.removeAt(0);
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'Moved ${ackHashes.length} ACK hashes to history for message $messageId (history size: ${_ackHistory.length})',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -621,9 +556,6 @@ class MessageRetryService extends ChangeNotifier {
|
|||
for (final entry in _ackHistory) {
|
||||
for (final expectedHash in entry.ackHashes) {
|
||||
if (listEquals(expectedHash, ackHash)) {
|
||||
debugPrint(
|
||||
'Found ACK match in history: messageId=${entry.messageId}, age=${DateTime.now().difference(entry.timestamp).inSeconds}s',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -632,14 +564,14 @@ class MessageRetryService extends ChangeNotifier {
|
|||
}
|
||||
|
||||
void handleAckReceived(Uint8List ackHash, int tripTimeMs) {
|
||||
final config = _config;
|
||||
String? matchedMessageId;
|
||||
int? matchedAttemptIndex;
|
||||
final ackHashHex = ackHash
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
|
||||
debugPrint('ACK received: $ackHashHex, trip time: ${tripTimeMs}ms');
|
||||
|
||||
// First, clean up old ACK hash mappings (older than 15 minutes)
|
||||
// Clean up old ACK hash mappings (older than 15 minutes)
|
||||
final cutoffTime = DateTime.now().subtract(const Duration(minutes: 15));
|
||||
final hashesToRemove = <String>[];
|
||||
for (var entry in _ackHashToMessageId.entries) {
|
||||
|
|
@ -650,24 +582,18 @@ class MessageRetryService extends ChangeNotifier {
|
|||
for (var hash in hashesToRemove) {
|
||||
_ackHashToMessageId.remove(hash);
|
||||
}
|
||||
if (hashesToRemove.isNotEmpty) {
|
||||
debugPrint('Cleaned up ${hashesToRemove.length} old ACK hash mappings');
|
||||
}
|
||||
|
||||
// Use direct O(1) lookup via ACK hash mapping
|
||||
final mapping = _ackHashToMessageId[ackHashHex];
|
||||
if (mapping != null) {
|
||||
matchedMessageId = mapping.messageId;
|
||||
debugPrint('Matched ACK to message via direct lookup: $matchedMessageId');
|
||||
matchedAttemptIndex = mapping.attemptIndex;
|
||||
} else {
|
||||
_debugLogService?.warn(
|
||||
config?.debugLogService?.warn(
|
||||
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex not found in direct mapping, trying fallback',
|
||||
tag: 'AckHash',
|
||||
);
|
||||
// Fallback: Check against ALL expected ACK hashes (from all retry attempts)
|
||||
debugPrint(
|
||||
'ACK not in mapping, checking _expectedAckHashes (${_expectedAckHashes.length} messages)',
|
||||
);
|
||||
for (var entry in _expectedAckHashes.entries) {
|
||||
final messageId = entry.key;
|
||||
final expectedHashes = entry.value;
|
||||
|
|
@ -675,9 +601,7 @@ class MessageRetryService extends ChangeNotifier {
|
|||
for (final expectedHash in expectedHashes) {
|
||||
if (listEquals(expectedHash, ackHash)) {
|
||||
matchedMessageId = messageId;
|
||||
debugPrint(
|
||||
'Matched ACK to message via fallback: $matchedMessageId (attempt ${expectedHashes.indexOf(expectedHash)})',
|
||||
);
|
||||
matchedAttemptIndex = expectedHashes.indexOf(expectedHash);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -689,27 +613,22 @@ class MessageRetryService extends ChangeNotifier {
|
|||
if (matchedMessageId != null) {
|
||||
final message = _pendingMessages[matchedMessageId];
|
||||
if (message == null) {
|
||||
// Message was already cleaned up (e.g. grace period expired)
|
||||
_ackHashToMessageId.remove(ackHashHex);
|
||||
debugPrint(
|
||||
'ACK matched $matchedMessageId but message already cleaned up',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final contact = _pendingContacts[matchedMessageId];
|
||||
final selection = _pendingPathSelections[matchedMessageId];
|
||||
final ackedAttempt = matchedAttemptIndex ?? message.retryCount;
|
||||
final selection = _selectionFromMessage(message);
|
||||
|
||||
final shortText = message.text.length > 20
|
||||
? '${message.text.substring(0, 20)}...'
|
||||
: message.text;
|
||||
_debugLogService?.info(
|
||||
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex ✓ "$shortText" delivered to ${contact?.name ?? "unknown"} in ${tripTimeMs}ms',
|
||||
config?.debugLogService?.info(
|
||||
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex ✓ "$shortText" delivered to ${contact?.name ?? "unknown"} on retry ${ackedAttempt + 1} in ${tripTimeMs}ms',
|
||||
tag: 'AckHash',
|
||||
);
|
||||
|
||||
// Cancel any pending timeout or retry
|
||||
_timeoutTimers[matchedMessageId]?.cancel();
|
||||
_timeoutTimers.remove(matchedMessageId);
|
||||
|
||||
final deliveredMessage = message.copyWith(
|
||||
status: MessageStatus.delivered,
|
||||
|
|
@ -717,36 +636,9 @@ class MessageRetryService extends ChangeNotifier {
|
|||
tripTimeMs: tripTimeMs,
|
||||
);
|
||||
|
||||
// Clean up ALL hash mappings for this message (from all retry attempts)
|
||||
_ackHashToMessageId.removeWhere(
|
||||
(_, mapping) => mapping.messageId == matchedMessageId,
|
||||
);
|
||||
_expectedHashToMessageId.removeWhere(
|
||||
(_, msgId) => msgId == matchedMessageId,
|
||||
);
|
||||
_cleanupMessage(matchedMessageId);
|
||||
|
||||
// Move ACK hashes to history before removing
|
||||
_moveAckHashesToHistory(matchedMessageId);
|
||||
|
||||
_pendingMessages.remove(matchedMessageId);
|
||||
_pendingContacts.remove(matchedMessageId);
|
||||
_pendingPathSelections.remove(matchedMessageId);
|
||||
_resolvedMessages.remove(matchedMessageId);
|
||||
|
||||
// Clean up the queue entry for this contact (remove any remaining references to this message)
|
||||
if (contact != null) {
|
||||
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(
|
||||
matchedMessageId,
|
||||
);
|
||||
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
|
||||
false) {
|
||||
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
|
||||
}
|
||||
}
|
||||
|
||||
if (_updateMessageCallback != null) {
|
||||
_updateMessageCallback!(deliveredMessage);
|
||||
}
|
||||
config?.updateMessage(deliveredMessage);
|
||||
|
||||
if (contact != null) {
|
||||
_recordPathResultFromMessage(
|
||||
|
|
@ -756,10 +648,10 @@ class MessageRetryService extends ChangeNotifier {
|
|||
true,
|
||||
tripTimeMs,
|
||||
);
|
||||
if (_onDeliveryObservedCallback != null &&
|
||||
if (config?.onDeliveryObserved != null &&
|
||||
tripTimeMs > 0 &&
|
||||
message.pathLength != null) {
|
||||
_onDeliveryObservedCallback!(
|
||||
config!.onDeliveryObserved!(
|
||||
contact.publicKeyHex,
|
||||
message.pathLength!,
|
||||
message.text.length,
|
||||
|
|
@ -771,15 +663,13 @@ class MessageRetryService extends ChangeNotifier {
|
|||
|
||||
notifyListeners();
|
||||
} else {
|
||||
// Check ACK history for recently completed messages
|
||||
if (_checkAckHistory(ackHash)) {
|
||||
_debugLogService?.info(
|
||||
config?.debugLogService?.info(
|
||||
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex matched a recently completed message (duplicate ACK)',
|
||||
tag: 'AckHash',
|
||||
);
|
||||
debugPrint('ACK matched a recently completed message from history');
|
||||
} else {
|
||||
_debugLogService?.error(
|
||||
config?.debugLogService?.error(
|
||||
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex has no matching message!',
|
||||
tag: 'AckHash',
|
||||
);
|
||||
|
|
@ -788,57 +678,6 @@ class MessageRetryService extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
Uint8List _resolveMessagePathBytes(
|
||||
Contact contact,
|
||||
bool forceFlood,
|
||||
PathSelection? selection,
|
||||
) {
|
||||
// Priority 1: Check user's path override
|
||||
if (contact.pathOverride != null) {
|
||||
if (contact.pathOverride! < 0) {
|
||||
return Uint8List(0); // Force flood
|
||||
}
|
||||
return contact.pathOverrideBytes ?? Uint8List(0);
|
||||
}
|
||||
|
||||
// Priority 2: Check forceFlood or device flood mode
|
||||
if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) {
|
||||
return Uint8List(0);
|
||||
}
|
||||
|
||||
// Priority 3: Check PathSelection (auto-rotation)
|
||||
if (selection != null && selection.pathBytes.isNotEmpty) {
|
||||
return Uint8List.fromList(selection.pathBytes);
|
||||
}
|
||||
|
||||
// Priority 4: Use device's discovered path
|
||||
return contact.path;
|
||||
}
|
||||
|
||||
int? _resolveMessagePathLength(
|
||||
Contact contact,
|
||||
bool forceFlood,
|
||||
PathSelection? selection,
|
||||
) {
|
||||
// Priority 1: Check user's path override
|
||||
if (contact.pathOverride != null) {
|
||||
return contact.pathOverride;
|
||||
}
|
||||
|
||||
// Priority 2: Check forceFlood or device flood mode
|
||||
if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Priority 3: Check PathSelection (auto-rotation)
|
||||
if (selection != null && selection.pathBytes.isNotEmpty) {
|
||||
return selection.hopCount;
|
||||
}
|
||||
|
||||
// Priority 4: Use device's discovered path
|
||||
return contact.pathLength;
|
||||
}
|
||||
|
||||
String? getContactKeyForAckHash(Uint8List ackHash) {
|
||||
for (var entry in _pendingMessages.entries) {
|
||||
final message = entry.value;
|
||||
|
|
@ -866,15 +705,11 @@ class MessageRetryService extends ChangeNotifier {
|
|||
bool success,
|
||||
int? tripTimeMs,
|
||||
) {
|
||||
if (_recordPathResultCallback == null) return;
|
||||
final callback = _config?.recordPathResult;
|
||||
if (callback == null) return;
|
||||
final recordSelection = selection ?? _selectionFromMessage(message);
|
||||
if (recordSelection == null) return;
|
||||
_recordPathResultCallback!(
|
||||
contactKey,
|
||||
recordSelection,
|
||||
success,
|
||||
tripTimeMs,
|
||||
);
|
||||
callback(contactKey, recordSelection, success, tripTimeMs);
|
||||
}
|
||||
|
||||
PathSelection? _selectionFromMessage(Message message) {
|
||||
|
|
@ -899,11 +734,10 @@ class MessageRetryService extends ChangeNotifier {
|
|||
_timeoutTimers.clear();
|
||||
_pendingMessages.clear();
|
||||
_pendingContacts.clear();
|
||||
_pendingPathSelections.clear();
|
||||
_attemptPathHistory.clear();
|
||||
_expectedAckHashes.clear();
|
||||
_ackHistory.clear();
|
||||
_ackHashToMessageId.clear();
|
||||
_pendingMessageQueuePerContact.clear();
|
||||
_sendQueue.clear();
|
||||
_activeMessages.clear();
|
||||
_resolvedMessages.clear();
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'dart:ui';
|
|||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../helpers/reaction_helper.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../utils/platform_info.dart';
|
||||
|
||||
|
|
@ -145,6 +146,19 @@ class NotificationService {
|
|||
return true;
|
||||
}
|
||||
|
||||
/// Format special message types for human-readable notifications.
|
||||
static String formatNotificationText(String text) {
|
||||
final trimmed = text.trim();
|
||||
final reaction = ReactionHelper.parseReaction(trimmed);
|
||||
if (reaction != null) {
|
||||
return 'Reacted ${reaction.emoji}';
|
||||
}
|
||||
if (RegExp(r'^g:[A-Za-z0-9_-]+$').hasMatch(trimmed)) {
|
||||
return 'Sent a GIF';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
Future<void> _showMessageNotificationImpl({
|
||||
required String contactName,
|
||||
required String message,
|
||||
|
|
@ -187,7 +201,7 @@ class NotificationService {
|
|||
await _notifications.show(
|
||||
id: contactId?.hashCode ?? 0,
|
||||
title: contactName,
|
||||
body: message,
|
||||
body: formatNotificationText(message),
|
||||
notificationDetails: notificationDetails,
|
||||
payload: 'message:$contactId',
|
||||
);
|
||||
|
|
@ -283,7 +297,7 @@ class NotificationService {
|
|||
macOS: macDetails,
|
||||
);
|
||||
|
||||
final preview = message.trim();
|
||||
final preview = formatNotificationText(message.trim());
|
||||
final body = preview.isEmpty
|
||||
? _l10n.notification_receivedNewMessage
|
||||
: preview;
|
||||
|
|
@ -430,6 +444,7 @@ class NotificationService {
|
|||
|
||||
Future<void> showChannelMessageNotification({
|
||||
required String channelName,
|
||||
required String senderName,
|
||||
required String message,
|
||||
int? channelIndex,
|
||||
int? badgeCount,
|
||||
|
|
@ -440,7 +455,7 @@ class NotificationService {
|
|||
_PendingNotification(
|
||||
type: _NotificationType.channelMessage,
|
||||
title: channelName,
|
||||
body: message,
|
||||
body: '$senderName: $message',
|
||||
id: channelIndex?.toString(),
|
||||
badgeCount: badgeCount,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ class PathHistoryService extends ChangeNotifier {
|
|||
final Map<String, ContactPathHistory> _cache = {};
|
||||
final Map<String, int> _autoRotationIndex = {};
|
||||
final Map<String, _FloodStats> _floodStats = {};
|
||||
final Set<String> _pendingLoads = {};
|
||||
final Map<String, List<_DeferredPathRecord>> _deferredRecords = {};
|
||||
|
||||
// LRU cache eviction tracking
|
||||
static const int _maxCachedContacts = 50;
|
||||
|
|
@ -18,7 +20,6 @@ class PathHistoryService extends ChangeNotifier {
|
|||
|
||||
int _version = 0;
|
||||
int get version => _version;
|
||||
static const int _autoRotationTopCount = 3;
|
||||
|
||||
PathHistoryService(this._storage);
|
||||
|
||||
|
|
@ -26,17 +27,21 @@ class PathHistoryService extends ChangeNotifier {
|
|||
// Load cached path histories on startup if needed
|
||||
}
|
||||
|
||||
void handlePathUpdated(Contact contact) {
|
||||
if (contact.pathLength < 0) return;
|
||||
|
||||
void handlePathUpdated(Contact contact, {double initialWeight = 1.0}) {
|
||||
if (contact.pathLength < 0 && contact.path.isEmpty) return;
|
||||
final hopCount = contact.pathLength < 0
|
||||
? contact.path.length
|
||||
: contact.pathLength;
|
||||
_addPathRecord(
|
||||
contactPubKeyHex: contact.publicKeyHex,
|
||||
hopCount: contact.pathLength,
|
||||
hopCount: hopCount,
|
||||
tripTimeMs: 0,
|
||||
wasFloodDiscovery: true,
|
||||
pathBytes: contact.path,
|
||||
successCount: 0,
|
||||
failureCount: 0,
|
||||
routeWeight: initialWeight,
|
||||
timestamp: null,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -54,6 +59,44 @@ class PathHistoryService extends ChangeNotifier {
|
|||
pathBytes: selection.pathBytes,
|
||||
successCount: 0,
|
||||
failureCount: 0,
|
||||
timestamp: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// When a flood message is delivered, credit the contact's current device
|
||||
/// path so that the route the ACK traveled back through gets a weight boost.
|
||||
void recordFloodPathAttribution({
|
||||
required String contactPubKeyHex,
|
||||
required List<int> pathBytes,
|
||||
required int hopCount,
|
||||
int? tripTimeMs,
|
||||
double successIncrement = 0.5,
|
||||
double maxWeight = 5.0,
|
||||
}) {
|
||||
if (pathBytes.isEmpty || hopCount < 0) return;
|
||||
|
||||
final existing = _findPathRecord(contactPubKeyHex, pathBytes);
|
||||
final successCount = (existing?.successCount ?? 0) + 1;
|
||||
final failureCount = existing?.failureCount ?? 0;
|
||||
|
||||
final currentWeight = existing?.routeWeight ?? 1.0;
|
||||
final newWeight = (currentWeight + successIncrement).clamp(0.0, maxWeight);
|
||||
|
||||
debugPrint(
|
||||
'Flood path attribution: crediting path [${pathBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(',')}] '
|
||||
'for $contactPubKeyHex (weight $currentWeight → $newWeight)',
|
||||
);
|
||||
|
||||
_addPathRecord(
|
||||
contactPubKeyHex: contactPubKeyHex,
|
||||
hopCount: hopCount,
|
||||
tripTimeMs: tripTimeMs ?? existing?.tripTimeMs ?? 0,
|
||||
wasFloodDiscovery: true,
|
||||
pathBytes: pathBytes,
|
||||
successCount: successCount,
|
||||
failureCount: failureCount,
|
||||
routeWeight: newWeight,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -62,6 +105,9 @@ class PathHistoryService extends ChangeNotifier {
|
|||
PathSelection selection, {
|
||||
required bool success,
|
||||
int? tripTimeMs,
|
||||
double successIncrement = 0.5,
|
||||
double failureDecrement = 0.5,
|
||||
double maxWeight = 5.0,
|
||||
}) {
|
||||
if (selection.useFlood) {
|
||||
final stats = _floodStats.putIfAbsent(
|
||||
|
|
@ -82,6 +128,18 @@ class PathHistoryService extends ChangeNotifier {
|
|||
final successCount = (existing?.successCount ?? 0) + (success ? 1 : 0);
|
||||
final failureCount = (existing?.failureCount ?? 0) + (success ? 0 : 1);
|
||||
|
||||
final currentWeight = existing?.routeWeight ?? 1.0;
|
||||
double newWeight;
|
||||
if (success) {
|
||||
newWeight = (currentWeight + successIncrement).clamp(0.0, maxWeight);
|
||||
} else {
|
||||
newWeight = currentWeight - failureDecrement;
|
||||
if (newWeight <= 0) {
|
||||
removePathRecord(contactPubKeyHex, selection.pathBytes);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_addPathRecord(
|
||||
contactPubKeyHex: contactPubKeyHex,
|
||||
hopCount: selection.hopCount,
|
||||
|
|
@ -90,37 +148,68 @@ class PathHistoryService extends ChangeNotifier {
|
|||
pathBytes: selection.pathBytes,
|
||||
successCount: successCount,
|
||||
failureCount: failureCount,
|
||||
routeWeight: newWeight,
|
||||
timestamp: success ? DateTime.now() : existing?.timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
PathSelection getNextAutoPathSelection(String contactPubKeyHex) {
|
||||
final ranked = _getRankedPaths(
|
||||
contactPubKeyHex,
|
||||
).take(_autoRotationTopCount).toList();
|
||||
PathSelection selectPathForAttempt(
|
||||
String contactPubKeyHex, {
|
||||
required int attemptIndex,
|
||||
required int maxRetries,
|
||||
List<PathSelection> recentSelections = const [],
|
||||
}) {
|
||||
if (maxRetries <= 0 || attemptIndex >= maxRetries - 1) {
|
||||
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
|
||||
}
|
||||
|
||||
final ranked = _getRankedPaths(contactPubKeyHex);
|
||||
if (ranked.isEmpty) {
|
||||
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
|
||||
}
|
||||
|
||||
_trackAccess(contactPubKeyHex);
|
||||
|
||||
final selections =
|
||||
ranked
|
||||
.map(
|
||||
(path) => PathSelection(
|
||||
pathBytes: path.pathBytes,
|
||||
hopCount: path.hopCount,
|
||||
useFlood: false,
|
||||
),
|
||||
)
|
||||
.toList()
|
||||
..add(
|
||||
const PathSelection(pathBytes: [], hopCount: -1, useFlood: true),
|
||||
);
|
||||
final recentPaths = recentSelections
|
||||
.where((selection) => !selection.useFlood)
|
||||
.map((selection) => selection.pathBytes)
|
||||
.toList();
|
||||
final candidates = recentPaths.isEmpty
|
||||
? ranked
|
||||
: ranked
|
||||
.where(
|
||||
(path) => !recentPaths.any(
|
||||
(recentPath) => _pathsEqual(path.pathBytes, recentPath),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
final selected = candidates.isNotEmpty
|
||||
? (recentPaths.isEmpty
|
||||
? _selectRotatedCandidate(contactPubKeyHex, candidates)
|
||||
: candidates.first)
|
||||
: ranked.first;
|
||||
|
||||
return PathSelection(
|
||||
pathBytes: selected.pathBytes,
|
||||
hopCount: selected.hopCount,
|
||||
useFlood: false,
|
||||
);
|
||||
}
|
||||
|
||||
PathRecord _selectRotatedCandidate(
|
||||
String contactPubKeyHex,
|
||||
List<PathRecord> candidates,
|
||||
) {
|
||||
if (candidates.length <= 1) {
|
||||
_autoRotationIndex[contactPubKeyHex] = 0;
|
||||
return candidates.first;
|
||||
}
|
||||
|
||||
final currentIndex = _autoRotationIndex[contactPubKeyHex] ?? 0;
|
||||
final selection = selections[currentIndex % selections.length];
|
||||
_autoRotationIndex[contactPubKeyHex] = currentIndex + 1;
|
||||
return selection;
|
||||
final selectedIndex = currentIndex % candidates.length;
|
||||
_autoRotationIndex[contactPubKeyHex] =
|
||||
(selectedIndex + 1) % candidates.length;
|
||||
return candidates[selectedIndex];
|
||||
}
|
||||
|
||||
void _addPathRecord({
|
||||
|
|
@ -131,37 +220,68 @@ class PathHistoryService extends ChangeNotifier {
|
|||
required List<int> pathBytes,
|
||||
required int successCount,
|
||||
required int failureCount,
|
||||
double routeWeight = 1.0,
|
||||
DateTime? timestamp,
|
||||
}) {
|
||||
var history = _cache[contactPubKeyHex];
|
||||
|
||||
if (history == null) {
|
||||
// If a load is already in progress, defer this record
|
||||
if (_pendingLoads.contains(contactPubKeyHex)) {
|
||||
_deferredRecords.putIfAbsent(contactPubKeyHex, () => []);
|
||||
_deferredRecords[contactPubKeyHex]!.add(
|
||||
_DeferredPathRecord(
|
||||
hopCount: hopCount,
|
||||
tripTimeMs: tripTimeMs,
|
||||
wasFloodDiscovery: wasFloodDiscovery,
|
||||
pathBytes: pathBytes,
|
||||
successCount: successCount,
|
||||
failureCount: failureCount,
|
||||
routeWeight: routeWeight,
|
||||
timestamp: timestamp,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingLoads.add(contactPubKeyHex);
|
||||
_loadHistoryFromStorage(contactPubKeyHex).then((loaded) {
|
||||
if (loaded != null) {
|
||||
_cache[contactPubKeyHex] = loaded;
|
||||
_addPathRecordInternal(
|
||||
contactPubKeyHex,
|
||||
hopCount,
|
||||
tripTimeMs,
|
||||
wasFloodDiscovery,
|
||||
pathBytes,
|
||||
successCount,
|
||||
failureCount,
|
||||
);
|
||||
} else {
|
||||
_cache[contactPubKeyHex] = ContactPathHistory(
|
||||
contactPubKeyHex: contactPubKeyHex,
|
||||
recentPaths: [],
|
||||
);
|
||||
_addPathRecordInternal(
|
||||
contactPubKeyHex,
|
||||
hopCount,
|
||||
tripTimeMs,
|
||||
wasFloodDiscovery,
|
||||
pathBytes,
|
||||
successCount,
|
||||
failureCount,
|
||||
);
|
||||
_cache[contactPubKeyHex] =
|
||||
loaded ??
|
||||
ContactPathHistory(
|
||||
contactPubKeyHex: contactPubKeyHex,
|
||||
recentPaths: [],
|
||||
);
|
||||
_addPathRecordInternal(
|
||||
contactPubKeyHex,
|
||||
hopCount,
|
||||
tripTimeMs,
|
||||
wasFloodDiscovery,
|
||||
pathBytes,
|
||||
successCount,
|
||||
failureCount,
|
||||
routeWeight,
|
||||
timestamp,
|
||||
);
|
||||
|
||||
// Apply any deferred records
|
||||
final deferred = _deferredRecords.remove(contactPubKeyHex);
|
||||
if (deferred != null) {
|
||||
for (final record in deferred) {
|
||||
_addPathRecordInternal(
|
||||
contactPubKeyHex,
|
||||
record.hopCount,
|
||||
record.tripTimeMs,
|
||||
record.wasFloodDiscovery,
|
||||
record.pathBytes,
|
||||
record.successCount,
|
||||
record.failureCount,
|
||||
record.routeWeight,
|
||||
record.timestamp,
|
||||
);
|
||||
}
|
||||
}
|
||||
_pendingLoads.remove(contactPubKeyHex);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -174,6 +294,8 @@ class PathHistoryService extends ChangeNotifier {
|
|||
pathBytes,
|
||||
successCount,
|
||||
failureCount,
|
||||
routeWeight,
|
||||
timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -185,6 +307,8 @@ class PathHistoryService extends ChangeNotifier {
|
|||
List<int> pathBytes,
|
||||
int successCount,
|
||||
int failureCount,
|
||||
double routeWeight,
|
||||
DateTime? timestamp,
|
||||
) {
|
||||
var history = _cache[contactPubKeyHex];
|
||||
if (history == null) return;
|
||||
|
|
@ -198,16 +322,18 @@ class PathHistoryService extends ChangeNotifier {
|
|||
tripTimeMs = existing.tripTimeMs;
|
||||
}
|
||||
wasFloodDiscovery = existing.wasFloodDiscovery || wasFloodDiscovery;
|
||||
timestamp ??= existing.timestamp;
|
||||
}
|
||||
|
||||
final newRecord = PathRecord(
|
||||
hopCount: hopCount,
|
||||
tripTimeMs: tripTimeMs,
|
||||
timestamp: DateTime.now(),
|
||||
timestamp: timestamp,
|
||||
wasFloodDiscovery: wasFloodDiscovery,
|
||||
pathBytes: pathBytes,
|
||||
successCount: successCount,
|
||||
failureCount: failureCount,
|
||||
routeWeight: routeWeight,
|
||||
);
|
||||
|
||||
final updatedPaths = List<PathRecord>.from(history.recentPaths);
|
||||
|
|
@ -275,6 +401,23 @@ class PathHistoryService extends ChangeNotifier {
|
|||
return history?.mostRecent;
|
||||
}
|
||||
|
||||
({
|
||||
int successCount,
|
||||
int failureCount,
|
||||
int lastTripTimeMs,
|
||||
DateTime? lastUsed,
|
||||
})?
|
||||
getFloodStats(String contactPubKeyHex) {
|
||||
final stats = _floodStats[contactPubKeyHex];
|
||||
if (stats == null) return null;
|
||||
return (
|
||||
successCount: stats.successCount,
|
||||
failureCount: stats.failureCount,
|
||||
lastTripTimeMs: stats.lastTripTimeMs,
|
||||
lastUsed: stats.lastUsed,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clearPathHistory(String contactPubKeyHex) async {
|
||||
_cache.remove(contactPubKeyHex);
|
||||
_cacheAccessOrder.remove(contactPubKeyHex);
|
||||
|
|
@ -322,26 +465,81 @@ class PathHistoryService extends ChangeNotifier {
|
|||
|
||||
final ranked = List<PathRecord>.from(history.recentPaths)
|
||||
..removeWhere((p) => p.pathBytes.isEmpty);
|
||||
final fastestTripMs = _getFastestKnownTripMs(ranked);
|
||||
final highestRouteWeight = _getHighestKnownRouteWeight(ranked);
|
||||
|
||||
ranked.sort((a, b) {
|
||||
final aRate =
|
||||
(a.successCount + 1) / (a.successCount + a.failureCount + 2);
|
||||
final bRate =
|
||||
(b.successCount + 1) / (b.successCount + b.failureCount + 2);
|
||||
if (aRate != bRate) return bRate.compareTo(aRate);
|
||||
if (a.successCount != b.successCount) {
|
||||
return b.successCount.compareTo(a.successCount);
|
||||
final scoreCompare = _scorePathRecord(
|
||||
b,
|
||||
fastestTripMs: fastestTripMs,
|
||||
highestRouteWeight: highestRouteWeight,
|
||||
).compareTo(
|
||||
_scorePathRecord(
|
||||
a,
|
||||
fastestTripMs: fastestTripMs,
|
||||
highestRouteWeight: highestRouteWeight,
|
||||
),
|
||||
);
|
||||
if (scoreCompare != 0) {
|
||||
return scoreCompare;
|
||||
}
|
||||
if (a.routeWeight != b.routeWeight) {
|
||||
return b.routeWeight.compareTo(a.routeWeight);
|
||||
}
|
||||
|
||||
final aTrip = a.tripTimeMs == 0 ? 999999 : a.tripTimeMs;
|
||||
final bTrip = b.tripTimeMs == 0 ? 999999 : b.tripTimeMs;
|
||||
if (aTrip != bTrip) return aTrip.compareTo(bTrip);
|
||||
return b.timestamp.compareTo(a.timestamp);
|
||||
final aTime = a.timestamp ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
final bTime = b.timestamp ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
return bTime.compareTo(aTime);
|
||||
});
|
||||
|
||||
return ranked;
|
||||
}
|
||||
|
||||
int? _getFastestKnownTripMs(List<PathRecord> paths) {
|
||||
final knownTrips = paths
|
||||
.where((path) => path.tripTimeMs > 0)
|
||||
.map((path) => path.tripTimeMs)
|
||||
.toList();
|
||||
if (knownTrips.isEmpty) return null;
|
||||
return knownTrips.reduce((a, b) => a < b ? a : b);
|
||||
}
|
||||
|
||||
double _getHighestKnownRouteWeight(List<PathRecord> paths) {
|
||||
if (paths.isEmpty) return 1.0;
|
||||
final highestWeight = paths
|
||||
.map((path) => path.routeWeight)
|
||||
.reduce((a, b) => a > b ? a : b);
|
||||
return highestWeight <= 0 ? 1.0 : highestWeight;
|
||||
}
|
||||
|
||||
double _scorePathRecord(
|
||||
PathRecord path, {
|
||||
required int? fastestTripMs,
|
||||
required double highestRouteWeight,
|
||||
}) {
|
||||
final totalAttempts = path.successCount + path.failureCount;
|
||||
final reliability = (path.successCount + 1) / (totalAttempts + 2);
|
||||
final latency = fastestTripMs == null || path.tripTimeMs <= 0
|
||||
? 0.6
|
||||
: (fastestTripMs / path.tripTimeMs).clamp(0.0, 1.0);
|
||||
final freshness = path.timestamp == null
|
||||
? 0.0
|
||||
: 1.0 /
|
||||
(1.0 +
|
||||
(DateTime.now().difference(path.timestamp!).inMinutes /
|
||||
60.0 /
|
||||
24.0));
|
||||
final routeWeight =
|
||||
(path.routeWeight / highestRouteWeight).clamp(0.0, 1.0);
|
||||
|
||||
return (reliability * 0.45) +
|
||||
(latency * 0.25) +
|
||||
(freshness * 0.1) +
|
||||
(routeWeight * 0.2);
|
||||
}
|
||||
|
||||
bool _pathsEqual(List<int> a, List<int> b) {
|
||||
return listEquals(a, b);
|
||||
}
|
||||
|
|
@ -369,6 +567,28 @@ class PathHistoryService extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
class _DeferredPathRecord {
|
||||
final int hopCount;
|
||||
final int tripTimeMs;
|
||||
final bool wasFloodDiscovery;
|
||||
final List<int> pathBytes;
|
||||
final int successCount;
|
||||
final int failureCount;
|
||||
final double routeWeight;
|
||||
final DateTime? timestamp;
|
||||
|
||||
_DeferredPathRecord({
|
||||
required this.hopCount,
|
||||
required this.tripTimeMs,
|
||||
required this.wasFloodDiscovery,
|
||||
required this.pathBytes,
|
||||
required this.successCount,
|
||||
required this.failureCount,
|
||||
this.routeWeight = 1.0,
|
||||
this.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
class _FloodStats {
|
||||
int successCount = 0;
|
||||
int failureCount = 0;
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ class ChannelMessageStore {
|
|||
'pathVariants': msg.pathVariants.map(base64Encode).toList(),
|
||||
'repeats': msg.repeats.map(_repeatToJson).toList(),
|
||||
'messageId': msg.messageId,
|
||||
'packetHash': msg.packetHash,
|
||||
'replyToMessageId': msg.replyToMessageId,
|
||||
'replyToSenderName': msg.replyToSenderName,
|
||||
'replyToText': msg.replyToText,
|
||||
|
|
@ -143,6 +144,7 @@ class ChannelMessageStore {
|
|||
const [],
|
||||
channelIndex: json['channelIndex'] as int?,
|
||||
messageId: json['messageId'] as String?,
|
||||
packetHash: json['packetHash'] as String?,
|
||||
replyToMessageId: json['replyToMessageId'] as String?,
|
||||
replyToSenderName: json['replyToSenderName'] as String?,
|
||||
replyToText: json['replyToText'] as String?,
|
||||
|
|
|
|||
|
|
@ -96,6 +96,9 @@ class MessageStore {
|
|||
? base64Encode(msg.pathBytes)
|
||||
: null,
|
||||
'reactions': msg.reactions,
|
||||
'reactionStatuses': msg.reactionStatuses.map(
|
||||
(key, value) => MapEntry(key, value.index),
|
||||
),
|
||||
'fourByteRoomContactKey': base64Encode(msg.fourByteRoomContactKey),
|
||||
};
|
||||
}
|
||||
|
|
@ -135,6 +138,11 @@ class MessageStore {
|
|||
(key, value) => MapEntry(key, value as int),
|
||||
) ??
|
||||
{},
|
||||
reactionStatuses:
|
||||
(json['reactionStatuses'] as Map<String, dynamic>?)?.map(
|
||||
(key, value) => MapEntry(key, MessageStatus.values[value as int]),
|
||||
) ??
|
||||
{},
|
||||
fourByteRoomContactKey: json['fourByteRoomContactKey'] != null
|
||||
? Uint8List.fromList(
|
||||
base64Decode(json['fourByteRoomContactKey'] as String),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import 'package:provider/provider.dart';
|
|||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../helpers/path_helper.dart';
|
||||
import '../services/path_history_service.dart';
|
||||
import 'path_selection_dialog.dart';
|
||||
|
||||
|
|
@ -40,7 +41,8 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
|
|||
);
|
||||
}
|
||||
|
||||
String _formatRelativeTime(BuildContext context, DateTime time) {
|
||||
String _formatRelativeTime(BuildContext context, DateTime? time) {
|
||||
if (time == null) return '—';
|
||||
final l10n = context.l10n;
|
||||
final diff = DateTime.now().difference(time);
|
||||
if (diff.inSeconds < 60) return l10n.time_justNow;
|
||||
|
|
@ -61,15 +63,31 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
|
|||
return;
|
||||
}
|
||||
|
||||
final formattedPath = pathBytes
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
||||
.join(',');
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final allContacts = connector.allContacts;
|
||||
|
||||
final formattedPath = PathHelper.formatPathHex(pathBytes);
|
||||
final resolvedNames = PathHelper.resolvePathNames(pathBytes, allContacts);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(l10n.chat_fullPath),
|
||||
content: SelectableText(formattedPath),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SelectableText(formattedPath),
|
||||
const SizedBox(height: 8),
|
||||
SelectableText(
|
||||
resolvedNames,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.push(
|
||||
|
|
@ -262,16 +280,17 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
|
|||
radius: 16,
|
||||
backgroundColor: color,
|
||||
child: Text(
|
||||
'${path.hopCount}',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
path.routeWeight.toStringAsFixed(1),
|
||||
style: const TextStyle(fontSize: 10),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
l10n.chat_hopsCount(path.hopCount),
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
isThreeLine: true,
|
||||
subtitle: Text(
|
||||
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(context, path.timestamp)} • ${path.successCount} ${l10n.chat_successes}',
|
||||
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(context, path.timestamp)}\n${path.successCount} ${l10n.chat_successes} • Score: ${path.routeWeight.toStringAsFixed(1)}',
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
trailing: Row(
|
||||
|
|
@ -346,6 +365,40 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
|
|||
Text(l10n.chat_noPathHistoryYet),
|
||||
const Divider(),
|
||||
],
|
||||
// Flood delivery stats
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final floodStats = pathService.getFloodStats(
|
||||
currentContact.publicKeyHex,
|
||||
);
|
||||
if (floodStats == null ||
|
||||
(floodStats.successCount == 0 &&
|
||||
floodStats.failureCount == 0)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: const CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Colors.blue,
|
||||
child: Icon(Icons.waves, size: 16),
|
||||
),
|
||||
title: const Text(
|
||||
'Flood Mode',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${floodStats.successCount} ${l10n.chat_successes} / ${floodStats.failureCount} failures'
|
||||
'${floodStats.lastTripTimeMs > 0 ? ' • ${(floodStats.lastTripTimeMs / 1000).toStringAsFixed(2)}s' : ''}'
|
||||
'${floodStats.lastUsed != null ? ' • ${_formatRelativeTime(context, floodStats.lastUsed!)}' : ''}',
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.chat_pathActions,
|
||||
|
|
|
|||
36
test/helpers/path_helper_test.dart
Normal file
36
test/helpers/path_helper_test.dart
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
||||
import 'package:meshcore_open/helpers/path_helper.dart';
|
||||
import 'package:meshcore_open/models/contact.dart';
|
||||
|
||||
Contact _contact({
|
||||
required int firstByte,
|
||||
required String name,
|
||||
required int type,
|
||||
}) {
|
||||
final key = Uint8List(32)..[0] = firstByte;
|
||||
return Contact(
|
||||
publicKey: key,
|
||||
name: name,
|
||||
type: type,
|
||||
pathLength: 0,
|
||||
path: Uint8List(0),
|
||||
lastSeen: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('resolvePathNames ignores chat nodes and keeps repeater/room nodes', () {
|
||||
final contacts = [
|
||||
_contact(firstByte: 0xF2, name: 'MunTui', type: advTypeChat),
|
||||
_contact(firstByte: 0x7E, name: 'zrepeater', type: advTypeRepeater),
|
||||
_contact(firstByte: 0xBA, name: 'USS Ronald Reagan', type: advTypeRoom),
|
||||
];
|
||||
|
||||
final resolved = PathHelper.resolvePathNames([0xF2, 0x7E, 0xBA], contacts);
|
||||
|
||||
expect(resolved, equals('F2 → zrepeater → USS Ronald Reagan'));
|
||||
});
|
||||
}
|
||||
357
test/models/model_changes_test.dart
Normal file
357
test/models/model_changes_test.dart
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_open/models/contact.dart';
|
||||
import 'package:meshcore_open/models/path_history.dart';
|
||||
import 'package:meshcore_open/models/app_settings.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
||||
|
||||
// Builds a valid contact frame with the given pathLen and optional overrides.
|
||||
// Frame layout: [respCode(1)][pubKey(32)][type(1)][flags(1)][pathLen(1)][path(64)][name(32)][timestamp(4)][lat(4)][lon(4)]
|
||||
Uint8List _buildContactFrame({
|
||||
int pathLen = 0,
|
||||
Uint8List? pubKey,
|
||||
String name = 'TestNode',
|
||||
}) {
|
||||
final writer = BytesBuilder();
|
||||
writer.addByte(respCodeContact); // 3
|
||||
writer.add(pubKey ?? Uint8List.fromList(List.generate(32, (i) => i + 1))); // valid pubkey
|
||||
writer.addByte(1); // type
|
||||
writer.addByte(0); // flags
|
||||
writer.addByte(pathLen);
|
||||
writer.add(Uint8List(64)); // path bytes (zeros)
|
||||
// name (32 bytes, null-padded)
|
||||
final nameBytes = Uint8List(32);
|
||||
final encoded = name.codeUnits;
|
||||
for (var i = 0; i < encoded.length && i < 31; i++) {
|
||||
nameBytes[i] = encoded[i];
|
||||
}
|
||||
writer.add(nameBytes);
|
||||
// timestamp (4 bytes LE) - some nonzero value
|
||||
writer.add(Uint8List.fromList([0x01, 0x00, 0x00, 0x00]));
|
||||
// lat, lon (4 bytes each)
|
||||
writer.add(Uint8List(4)); // lat
|
||||
writer.add(Uint8List(4)); // lon
|
||||
return Uint8List.fromList(writer.toBytes());
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('Contact.fromFrame — pathLen mapping', () {
|
||||
test('pathLen == 0 → pathLength == 0 (direct, NOT flood)', () {
|
||||
final frame = _buildContactFrame(pathLen: 0);
|
||||
final contact = Contact.fromFrame(frame);
|
||||
expect(contact, isNotNull);
|
||||
expect(contact!.pathLength, equals(0));
|
||||
});
|
||||
|
||||
test('pathLen == 1 → pathLength == 1', () {
|
||||
final frame = _buildContactFrame(pathLen: 1);
|
||||
final contact = Contact.fromFrame(frame);
|
||||
expect(contact, isNotNull);
|
||||
expect(contact!.pathLength, equals(1));
|
||||
});
|
||||
|
||||
test('pathLen == 64 (maxPathSize) → pathLength == 64', () {
|
||||
final frame = _buildContactFrame(pathLen: maxPathSize);
|
||||
final contact = Contact.fromFrame(frame);
|
||||
expect(contact, isNotNull);
|
||||
expect(contact!.pathLength, equals(maxPathSize));
|
||||
});
|
||||
|
||||
test('pathLen == 0xFF → pathLength == -1 (flood)', () {
|
||||
final frame = _buildContactFrame(pathLen: 0xFF);
|
||||
final contact = Contact.fromFrame(frame);
|
||||
expect(contact, isNotNull);
|
||||
expect(contact!.pathLength, equals(-1));
|
||||
});
|
||||
|
||||
test('pathLen == 65 (over maxPathSize) → pathLength == -1 (flood)', () {
|
||||
final frame = _buildContactFrame(pathLen: 65);
|
||||
final contact = Contact.fromFrame(frame);
|
||||
expect(contact, isNotNull);
|
||||
expect(contact!.pathLength, equals(-1));
|
||||
});
|
||||
});
|
||||
|
||||
group('Contact.fromFrame — corrupt contact guards', () {
|
||||
test('all-zero public key → returns null', () {
|
||||
final zeroPubKey = Uint8List(32); // all zeros
|
||||
final frame = _buildContactFrame(pubKey: zeroPubKey);
|
||||
final contact = Contact.fromFrame(frame);
|
||||
expect(contact, isNull);
|
||||
});
|
||||
|
||||
test('mostly-zero public key (>16 zeros out of 32) → returns null', () {
|
||||
// 17 zeros out of 32 bytes exceeds pubKeySize ~/ 2 == 16
|
||||
final pubKey = Uint8List(32);
|
||||
pubKey[0] = 0xAB;
|
||||
pubKey[1] = 0xCD;
|
||||
pubKey[2] = 0xEF;
|
||||
pubKey[3] = 0x12;
|
||||
pubKey[4] = 0x34;
|
||||
pubKey[5] = 0x56;
|
||||
pubKey[6] = 0x78;
|
||||
pubKey[7] = 0x9A;
|
||||
pubKey[8] = 0xBC;
|
||||
pubKey[9] = 0xDE;
|
||||
pubKey[10] = 0xF0;
|
||||
pubKey[11] = 0x11;
|
||||
pubKey[12] = 0x22;
|
||||
pubKey[13] = 0x33;
|
||||
pubKey[14] = 0x44;
|
||||
// bytes 15–31 are zero: that is 17 zeros (indices 15..31 inclusive)
|
||||
final frame = _buildContactFrame(pubKey: pubKey);
|
||||
final contact = Contact.fromFrame(frame);
|
||||
expect(contact, isNull);
|
||||
});
|
||||
|
||||
test('valid public key (few zeros) → returns Contact', () {
|
||||
// Only 1 zero → well below the threshold
|
||||
final pubKey = Uint8List.fromList(List.generate(32, (i) => i + 1));
|
||||
pubKey[5] = 0; // one zero byte
|
||||
final frame = _buildContactFrame(pubKey: pubKey);
|
||||
final contact = Contact.fromFrame(frame);
|
||||
expect(contact, isNotNull);
|
||||
});
|
||||
|
||||
test('name with all non-printable characters → returns null', () {
|
||||
// Build frame with a name composed entirely of control characters (< 0x20)
|
||||
final nameBytes = Uint8List(32);
|
||||
nameBytes[0] = 0x01;
|
||||
nameBytes[1] = 0x02;
|
||||
nameBytes[2] = 0x03;
|
||||
// remaining are 0x00 (null terminator ends the string after index 2,
|
||||
// so readCStringGreedy returns a 3-char string of non-printables)
|
||||
final writer = BytesBuilder();
|
||||
writer.addByte(respCodeContact);
|
||||
writer.add(Uint8List.fromList(List.generate(32, (i) => i + 1)));
|
||||
writer.addByte(1); // type
|
||||
writer.addByte(0); // flags
|
||||
writer.addByte(0); // pathLen
|
||||
writer.add(Uint8List(64)); // path
|
||||
writer.add(nameBytes);
|
||||
writer.add(Uint8List.fromList([0x01, 0x00, 0x00, 0x00])); // timestamp
|
||||
writer.add(Uint8List(4)); // lat
|
||||
writer.add(Uint8List(4)); // lon
|
||||
final frame = Uint8List.fromList(writer.toBytes());
|
||||
final contact = Contact.fromFrame(frame);
|
||||
expect(contact, isNull);
|
||||
});
|
||||
|
||||
test('name with valid printable characters → returns Contact', () {
|
||||
final frame = _buildContactFrame(name: 'Alice');
|
||||
final contact = Contact.fromFrame(frame);
|
||||
expect(contact, isNotNull);
|
||||
expect(contact!.name, equals('Alice'));
|
||||
});
|
||||
|
||||
test(
|
||||
'name with mix of printable and replacement chars → returns Contact (not all bad)',
|
||||
() {
|
||||
// Build a name with mostly printable chars and one replacement char (0xFFFD in codeUnits).
|
||||
// utf8 allowMalformed: true maps invalid sequences to U+FFFD.
|
||||
// We embed one invalid UTF-8 byte (0x80) among valid ASCII bytes.
|
||||
// The decoded string will be "Hi\uFFFDThere" — not ALL bad, so should be accepted.
|
||||
final nameBytes = Uint8List(32);
|
||||
nameBytes[0] = 0x48; // 'H'
|
||||
nameBytes[1] = 0x69; // 'i'
|
||||
nameBytes[2] = 0x80; // invalid UTF-8 → decoded as U+FFFD
|
||||
nameBytes[3] = 0x54; // 'T'
|
||||
nameBytes[4] = 0x68; // 'h'
|
||||
nameBytes[5] = 0x65; // 'e'
|
||||
nameBytes[6] = 0x72; // 'r'
|
||||
nameBytes[7] = 0x65; // 'e'
|
||||
// rest are 0x00 (null terminator)
|
||||
final writer = BytesBuilder();
|
||||
writer.addByte(respCodeContact);
|
||||
writer.add(Uint8List.fromList(List.generate(32, (i) => i + 1)));
|
||||
writer.addByte(1); // type
|
||||
writer.addByte(0); // flags
|
||||
writer.addByte(0); // pathLen
|
||||
writer.add(Uint8List(64)); // path
|
||||
writer.add(nameBytes);
|
||||
writer.add(Uint8List.fromList([0x01, 0x00, 0x00, 0x00])); // timestamp
|
||||
writer.add(Uint8List(4)); // lat
|
||||
writer.add(Uint8List(4)); // lon
|
||||
final frame = Uint8List.fromList(writer.toBytes());
|
||||
final contact = Contact.fromFrame(frame);
|
||||
expect(contact, isNotNull);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('PathRecord — routeWeight field', () {
|
||||
test('default routeWeight is 1.0', () {
|
||||
final record = PathRecord(
|
||||
hopCount: 2,
|
||||
tripTimeMs: 500,
|
||||
timestamp: DateTime(2024),
|
||||
wasFloodDiscovery: false,
|
||||
pathBytes: [0x01, 0x02],
|
||||
successCount: 1,
|
||||
failureCount: 0,
|
||||
);
|
||||
expect(record.routeWeight, equals(1.0));
|
||||
});
|
||||
|
||||
test('custom routeWeight is preserved', () {
|
||||
final record = PathRecord(
|
||||
hopCount: 3,
|
||||
tripTimeMs: 800,
|
||||
timestamp: DateTime(2024),
|
||||
wasFloodDiscovery: false,
|
||||
pathBytes: [0x01],
|
||||
successCount: 5,
|
||||
failureCount: 2,
|
||||
routeWeight: 3.5,
|
||||
);
|
||||
expect(record.routeWeight, equals(3.5));
|
||||
});
|
||||
|
||||
test('toJson includes route_weight', () {
|
||||
final record = PathRecord(
|
||||
hopCount: 1,
|
||||
tripTimeMs: 200,
|
||||
timestamp: DateTime(2024),
|
||||
wasFloodDiscovery: true,
|
||||
pathBytes: [],
|
||||
successCount: 0,
|
||||
failureCount: 0,
|
||||
routeWeight: 2.25,
|
||||
);
|
||||
final json = record.toJson();
|
||||
expect(json.containsKey('route_weight'), isTrue);
|
||||
expect(json['route_weight'], equals(2.25));
|
||||
});
|
||||
|
||||
test('fromJson reads route_weight', () {
|
||||
final json = {
|
||||
'hop_count': 2,
|
||||
'trip_time_ms': 400,
|
||||
'timestamp': DateTime(2024).toIso8601String(),
|
||||
'was_flood': false,
|
||||
'path_bytes': [1, 2, 3],
|
||||
'success_count': 3,
|
||||
'failure_count': 1,
|
||||
'route_weight': 4.0,
|
||||
};
|
||||
final record = PathRecord.fromJson(json);
|
||||
expect(record.routeWeight, equals(4.0));
|
||||
});
|
||||
|
||||
test('fromJson with missing route_weight defaults to 1.0 (backward compat)',
|
||||
() {
|
||||
final json = {
|
||||
'hop_count': 1,
|
||||
'trip_time_ms': 100,
|
||||
'timestamp': DateTime(2024).toIso8601String(),
|
||||
'was_flood': false,
|
||||
'path_bytes': [],
|
||||
'success_count': 0,
|
||||
'failure_count': 0,
|
||||
// 'route_weight' intentionally omitted
|
||||
};
|
||||
final record = PathRecord.fromJson(json);
|
||||
expect(record.routeWeight, equals(1.0));
|
||||
});
|
||||
});
|
||||
|
||||
group('AppSettings — new fields', () {
|
||||
test('default values are correct', () {
|
||||
final settings = AppSettings();
|
||||
expect(settings.maxRouteWeight, equals(5.0));
|
||||
expect(settings.initialRouteWeight, equals(3.0));
|
||||
expect(settings.routeWeightSuccessIncrement, equals(0.5));
|
||||
expect(settings.routeWeightFailureDecrement, equals(0.2));
|
||||
expect(settings.maxMessageRetries, equals(5));
|
||||
});
|
||||
|
||||
test('toJson includes all new fields', () {
|
||||
final settings = AppSettings();
|
||||
final json = settings.toJson();
|
||||
expect(json.containsKey('max_route_weight'), isTrue);
|
||||
expect(json.containsKey('initial_route_weight'), isTrue);
|
||||
expect(json.containsKey('route_weight_success_increment'), isTrue);
|
||||
expect(json.containsKey('route_weight_failure_decrement'), isTrue);
|
||||
expect(json.containsKey('max_message_retries'), isTrue);
|
||||
expect(json['max_route_weight'], equals(5.0));
|
||||
expect(json['initial_route_weight'], equals(3.0));
|
||||
expect(json['route_weight_success_increment'], equals(0.5));
|
||||
expect(json['route_weight_failure_decrement'], equals(0.2));
|
||||
expect(json['max_message_retries'], equals(5));
|
||||
});
|
||||
|
||||
test('fromJson reads all new fields', () {
|
||||
final json = {
|
||||
'max_route_weight': 10.0,
|
||||
'initial_route_weight': 2.0,
|
||||
'route_weight_success_increment': 1.0,
|
||||
'route_weight_failure_decrement': 1.5,
|
||||
'max_message_retries': 8,
|
||||
};
|
||||
final settings = AppSettings.fromJson(json);
|
||||
expect(settings.maxRouteWeight, equals(10.0));
|
||||
expect(settings.initialRouteWeight, equals(2.0));
|
||||
expect(settings.routeWeightSuccessIncrement, equals(1.0));
|
||||
expect(settings.routeWeightFailureDecrement, equals(1.5));
|
||||
expect(settings.maxMessageRetries, equals(8));
|
||||
});
|
||||
|
||||
test(
|
||||
'fromJson with missing new fields uses defaults (backward compat)',
|
||||
() {
|
||||
// Simulate an old settings JSON with none of the new fields
|
||||
final json = <String, dynamic>{};
|
||||
final settings = AppSettings.fromJson(json);
|
||||
expect(settings.maxRouteWeight, equals(5.0));
|
||||
expect(settings.initialRouteWeight, equals(3.0));
|
||||
expect(settings.routeWeightSuccessIncrement, equals(0.5));
|
||||
expect(settings.routeWeightFailureDecrement, equals(0.2));
|
||||
expect(settings.maxMessageRetries, equals(5));
|
||||
},
|
||||
);
|
||||
|
||||
test('copyWith works for maxRouteWeight', () {
|
||||
final settings = AppSettings();
|
||||
final updated = settings.copyWith(maxRouteWeight: 8.0);
|
||||
expect(updated.maxRouteWeight, equals(8.0));
|
||||
// Other fields should be unchanged
|
||||
expect(updated.initialRouteWeight, equals(settings.initialRouteWeight));
|
||||
expect(updated.maxMessageRetries, equals(settings.maxMessageRetries));
|
||||
});
|
||||
|
||||
test('copyWith works for initialRouteWeight', () {
|
||||
final settings = AppSettings();
|
||||
final updated = settings.copyWith(initialRouteWeight: 3.0);
|
||||
expect(updated.initialRouteWeight, equals(3.0));
|
||||
expect(updated.maxRouteWeight, equals(settings.maxRouteWeight));
|
||||
});
|
||||
|
||||
test('copyWith works for routeWeightSuccessIncrement', () {
|
||||
final settings = AppSettings();
|
||||
final updated = settings.copyWith(routeWeightSuccessIncrement: 0.25);
|
||||
expect(updated.routeWeightSuccessIncrement, equals(0.25));
|
||||
expect(
|
||||
updated.routeWeightFailureDecrement,
|
||||
equals(settings.routeWeightFailureDecrement),
|
||||
);
|
||||
});
|
||||
|
||||
test('copyWith works for routeWeightFailureDecrement', () {
|
||||
final settings = AppSettings();
|
||||
final updated = settings.copyWith(routeWeightFailureDecrement: 0.75);
|
||||
expect(updated.routeWeightFailureDecrement, equals(0.75));
|
||||
expect(
|
||||
updated.routeWeightSuccessIncrement,
|
||||
equals(settings.routeWeightSuccessIncrement),
|
||||
);
|
||||
});
|
||||
|
||||
test('copyWith works for maxMessageRetries', () {
|
||||
final settings = AppSettings();
|
||||
final updated = settings.copyWith(maxMessageRetries: 10);
|
||||
expect(updated.maxMessageRetries, equals(10));
|
||||
expect(updated.maxRouteWeight, equals(settings.maxRouteWeight));
|
||||
});
|
||||
});
|
||||
}
|
||||
815
test/services/path_history_service_test.dart
Normal file
815
test/services/path_history_service_test.dart
Normal file
|
|
@ -0,0 +1,815 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_open/models/contact.dart';
|
||||
import 'package:meshcore_open/models/path_history.dart';
|
||||
import 'package:meshcore_open/models/path_selection.dart';
|
||||
import 'package:meshcore_open/services/path_history_service.dart';
|
||||
import 'package:meshcore_open/services/storage_service.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake storage — no SharedPreferences dependency, all in-memory.
|
||||
// ---------------------------------------------------------------------------
|
||||
class FakeStorageService extends StorageService {
|
||||
final Map<String, ContactPathHistory> _store = {};
|
||||
|
||||
@override
|
||||
Future<void> savePathHistory(
|
||||
String contactPubKeyHex,
|
||||
ContactPathHistory history,
|
||||
) async {
|
||||
_store[contactPubKeyHex] = history;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ContactPathHistory?> loadPathHistory(String contactPubKeyHex) async {
|
||||
return _store[contactPubKeyHex];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearPathHistory(String contactPubKeyHex) async {
|
||||
_store.remove(contactPubKeyHex);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a minimal Contact with the given pubKeyHex, pathLength, and path.
|
||||
///
|
||||
/// [publicKeyHex] must be exactly 64 hex characters (32 bytes).
|
||||
Contact _makeContact({
|
||||
required String publicKeyHex,
|
||||
int pathLength = -1,
|
||||
List<int> path = const [],
|
||||
}) {
|
||||
assert(publicKeyHex.length == 64, 'publicKeyHex must be 64 chars');
|
||||
final bytes = Uint8List(32);
|
||||
for (int i = 0; i < 32; i++) {
|
||||
bytes[i] = int.parse(publicKeyHex.substring(i * 2, i * 2 + 2), radix: 16);
|
||||
}
|
||||
return Contact(
|
||||
publicKey: bytes,
|
||||
name: 'Test',
|
||||
type: 1,
|
||||
pathLength: pathLength,
|
||||
path: Uint8List.fromList(path),
|
||||
lastSeen: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// A 64-char hex string derived from a short tag (padded with zeros).
|
||||
String _hex(String tag) {
|
||||
// Convert tag to hex-safe characters, then pad
|
||||
final hexTag = tag.codeUnits
|
||||
.map((c) => c.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
return hexTag.padLeft(64, '0');
|
||||
}
|
||||
|
||||
/// Flush the microtask / async queue so that deferred storage loads complete.
|
||||
Future<void> _flush() async {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
|
||||
/// Seed the service's cache for [pubKeyHex] by adding one path record and
|
||||
/// waiting for the async storage-load path to complete.
|
||||
///
|
||||
/// Call this before making synchronous assertions on a contact that has never
|
||||
/// been seen by the service.
|
||||
Future<void> _seed(
|
||||
PathHistoryService svc,
|
||||
String pubKeyHex, {
|
||||
List<int> pathBytes = const [1],
|
||||
int hopCount = 1,
|
||||
double weight = 1.0,
|
||||
}) async {
|
||||
final contact = _makeContact(
|
||||
publicKeyHex: pubKeyHex,
|
||||
pathLength: hopCount,
|
||||
path: pathBytes,
|
||||
);
|
||||
svc.handlePathUpdated(contact, initialWeight: weight);
|
||||
await _flush();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void main() {
|
||||
late FakeStorageService storage;
|
||||
late PathHistoryService svc;
|
||||
|
||||
setUp(() {
|
||||
storage = FakeStorageService();
|
||||
svc = PathHistoryService(storage);
|
||||
});
|
||||
|
||||
group('path selection', () {
|
||||
test('empty path history returns flood', () {
|
||||
const pubKey =
|
||||
'0000000000000000000000000000000000000000000000000000000000000001';
|
||||
final selection = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: 0,
|
||||
maxRetries: 5,
|
||||
);
|
||||
expect(selection.useFlood, isTrue);
|
||||
});
|
||||
|
||||
test('returns flood when maxRetries == 0', () {
|
||||
const pubKey =
|
||||
'0000000000000000000000000000000000000000000000000000000000000001';
|
||||
final selection = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: 0,
|
||||
maxRetries: 0,
|
||||
);
|
||||
expect(selection.useFlood, isTrue);
|
||||
});
|
||||
|
||||
test('single known path is used for non-final attempts', () async {
|
||||
final pubKey = _hex('aabb');
|
||||
await _seed(svc, pubKey, pathBytes: [0x01, 0x02], hopCount: 2);
|
||||
|
||||
for (int i = 0; i < 4; i++) {
|
||||
final selection = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: i,
|
||||
maxRetries: 5,
|
||||
);
|
||||
expect(selection.useFlood, isFalse, reason: 'attempt $i should be path');
|
||||
expect(selection.pathBytes, equals([0x01, 0x02]));
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'retries avoid immediately repeating the same path when possible',
|
||||
() async {
|
||||
final pubKey = _hex('rot1');
|
||||
await _seed(svc, pubKey, pathBytes: [0xAA], hopCount: 1, weight: 1.0);
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0xBB], hopCount: 1, useFlood: false),
|
||||
success: true,
|
||||
successIncrement: 0.0,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
final first = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: 0,
|
||||
maxRetries: 5,
|
||||
);
|
||||
final second = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: 1,
|
||||
maxRetries: 5,
|
||||
recentSelections: [first],
|
||||
);
|
||||
|
||||
expect(first.useFlood, isFalse);
|
||||
expect(second.useFlood, isFalse);
|
||||
expect(second.pathBytes, isNot(equals(first.pathBytes)));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'retries avoid the last two paths when a third option exists',
|
||||
() async {
|
||||
final pubKey = _hex('rot2');
|
||||
await _seed(svc, pubKey, pathBytes: [0xA1], hopCount: 1, weight: 3.0);
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0xB2], hopCount: 1, useFlood: false),
|
||||
success: true,
|
||||
successIncrement: 1.0,
|
||||
);
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0xC3], hopCount: 1, useFlood: false),
|
||||
success: true,
|
||||
successIncrement: 0.0,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
final first = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: 0,
|
||||
maxRetries: 5,
|
||||
);
|
||||
final second = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: 1,
|
||||
maxRetries: 5,
|
||||
recentSelections: [first],
|
||||
);
|
||||
final third = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: 2,
|
||||
maxRetries: 5,
|
||||
recentSelections: [first, second],
|
||||
);
|
||||
|
||||
final chosenPaths = [
|
||||
first.pathBytes,
|
||||
second.pathBytes,
|
||||
third.pathBytes,
|
||||
];
|
||||
expect(
|
||||
chosenPaths
|
||||
.map((path) => path.map((b) => b.toRadixString(16)).join(','))
|
||||
.toSet()
|
||||
.length,
|
||||
equals(3),
|
||||
);
|
||||
expect(
|
||||
chosenPaths,
|
||||
everyElement(anyOf(equals([0xA1]), equals([0xB2]), equals([0xC3]))),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('first-attempt selection rotates across ranked candidates', () async {
|
||||
final pubKey = _hex('rot3');
|
||||
await _seed(svc, pubKey, pathBytes: [0xA1], hopCount: 1, weight: 4.0);
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0xB2], hopCount: 1, useFlood: false),
|
||||
success: true,
|
||||
successIncrement: 1.0,
|
||||
);
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0xC3], hopCount: 1, useFlood: false),
|
||||
success: true,
|
||||
successIncrement: 0.5,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
final first = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: 0,
|
||||
maxRetries: 5,
|
||||
);
|
||||
final second = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: 0,
|
||||
maxRetries: 5,
|
||||
);
|
||||
final third = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: 0,
|
||||
maxRetries: 5,
|
||||
);
|
||||
|
||||
expect(first.pathBytes, isNot(equals(second.pathBytes)));
|
||||
expect(second.pathBytes, isNot(equals(third.pathBytes)));
|
||||
expect(
|
||||
[first.pathBytes, second.pathBytes, third.pathBytes]
|
||||
.map((path) => path.map((b) => b.toRadixString(16)).join(','))
|
||||
.toSet()
|
||||
.length,
|
||||
equals(3),
|
||||
);
|
||||
});
|
||||
|
||||
test('final attempt is always flood regardless of known paths', () async {
|
||||
final pubKey = _hex('ef01');
|
||||
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1);
|
||||
|
||||
for (final retries in [1, 2, 5, 10]) {
|
||||
final lastAttempt = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: retries - 1,
|
||||
maxRetries: retries,
|
||||
);
|
||||
expect(
|
||||
lastAttempt.useFlood,
|
||||
isTrue,
|
||||
reason: 'maxRetries=$retries: last attempt must be flood',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('path scoring', () {
|
||||
test('higher reliability beats higher route weight', () async {
|
||||
final pubKey = _hex('rank1');
|
||||
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 4.5);
|
||||
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false),
|
||||
success: false,
|
||||
failureDecrement: 0.1,
|
||||
);
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false),
|
||||
success: false,
|
||||
failureDecrement: 0.1,
|
||||
);
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0x02], hopCount: 1, useFlood: false),
|
||||
success: true,
|
||||
successIncrement: 0.0,
|
||||
);
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0x02], hopCount: 1, useFlood: false),
|
||||
success: true,
|
||||
successIncrement: 0.0,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
final first = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: 0,
|
||||
maxRetries: 5,
|
||||
);
|
||||
expect(first.pathBytes, equals([0x02]));
|
||||
});
|
||||
|
||||
test('lower latency wins when reliability is tied', () async {
|
||||
final pubKey = _hex('rank2');
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0x10], hopCount: 1, useFlood: false),
|
||||
success: true,
|
||||
tripTimeMs: 1200,
|
||||
successIncrement: 0.0,
|
||||
);
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0x20], hopCount: 1, useFlood: false),
|
||||
success: true,
|
||||
tripTimeMs: 400,
|
||||
successIncrement: 0.0,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
final first = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: 0,
|
||||
maxRetries: 5,
|
||||
);
|
||||
expect(first.pathBytes, equals([0x20]));
|
||||
});
|
||||
|
||||
test('fresher path wins when reliability and latency are tied', () async {
|
||||
final pubKey = _hex('rank3');
|
||||
final oldTimestamp = DateTime.now().subtract(const Duration(days: 10));
|
||||
final newTimestamp = DateTime.now().subtract(const Duration(hours: 1));
|
||||
storage._store[pubKey] = ContactPathHistory(
|
||||
contactPubKeyHex: pubKey,
|
||||
recentPaths: [
|
||||
PathRecord(
|
||||
hopCount: 1,
|
||||
tripTimeMs: 900,
|
||||
timestamp: oldTimestamp,
|
||||
wasFloodDiscovery: false,
|
||||
pathBytes: const [0x01],
|
||||
successCount: 1,
|
||||
failureCount: 0,
|
||||
routeWeight: 1.0,
|
||||
),
|
||||
PathRecord(
|
||||
hopCount: 1,
|
||||
tripTimeMs: 900,
|
||||
timestamp: newTimestamp,
|
||||
wasFloodDiscovery: false,
|
||||
pathBytes: const [0x02],
|
||||
successCount: 1,
|
||||
failureCount: 0,
|
||||
routeWeight: 1.0,
|
||||
),
|
||||
],
|
||||
);
|
||||
svc.getRecentPaths(pubKey);
|
||||
await _flush();
|
||||
|
||||
final first = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: 0,
|
||||
maxRetries: 5,
|
||||
);
|
||||
expect(first.pathBytes, equals([0x02]));
|
||||
});
|
||||
|
||||
test('higher route weight wins when other factors are effectively tied', () async {
|
||||
final pubKey = _hex('rank4');
|
||||
final sharedTimestamp =
|
||||
DateTime.now().subtract(const Duration(minutes: 30));
|
||||
storage._store[pubKey] = ContactPathHistory(
|
||||
contactPubKeyHex: pubKey,
|
||||
recentPaths: [
|
||||
PathRecord(
|
||||
hopCount: 1,
|
||||
tripTimeMs: 750,
|
||||
timestamp: sharedTimestamp,
|
||||
wasFloodDiscovery: false,
|
||||
pathBytes: const [0x01],
|
||||
successCount: 1,
|
||||
failureCount: 0,
|
||||
routeWeight: 4.0,
|
||||
),
|
||||
PathRecord(
|
||||
hopCount: 1,
|
||||
tripTimeMs: 750,
|
||||
timestamp: sharedTimestamp,
|
||||
wasFloodDiscovery: false,
|
||||
pathBytes: const [0x02],
|
||||
successCount: 1,
|
||||
failureCount: 0,
|
||||
routeWeight: 1.0,
|
||||
),
|
||||
],
|
||||
);
|
||||
svc.getRecentPaths(pubKey);
|
||||
await _flush();
|
||||
|
||||
final first = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: 0,
|
||||
maxRetries: 5,
|
||||
);
|
||||
expect(first.pathBytes, equals([0x01]));
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Group 3: recordPathResult — weight adjustment
|
||||
// -------------------------------------------------------------------------
|
||||
group('recordPathResult weight adjustment', () {
|
||||
test('success increments weight by successIncrement', () async {
|
||||
final pubKey = _hex('w001');
|
||||
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 1.0);
|
||||
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false),
|
||||
success: true,
|
||||
successIncrement: 0.5,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
final paths = svc.getRecentPaths(pubKey);
|
||||
expect(paths, isNotEmpty);
|
||||
expect(paths.first.routeWeight, closeTo(1.5, 0.001));
|
||||
expect(paths.first.timestamp, isNotNull);
|
||||
});
|
||||
|
||||
test('attempts do not set timestamp before first success', () async {
|
||||
final pubKey = _hex('w000');
|
||||
|
||||
svc.recordPathAttempt(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false),
|
||||
);
|
||||
await _flush();
|
||||
|
||||
final paths = svc.getRecentPaths(pubKey);
|
||||
expect(paths, isNotEmpty);
|
||||
expect(paths.first.successCount, equals(0));
|
||||
expect(paths.first.timestamp, isNull);
|
||||
});
|
||||
|
||||
test('failure preserves the last success timestamp', () async {
|
||||
final pubKey = _hex('w006');
|
||||
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 1.0);
|
||||
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false),
|
||||
success: true,
|
||||
successIncrement: 0.0,
|
||||
);
|
||||
await _flush();
|
||||
final successTimestamp = svc.getRecentPaths(pubKey).first.timestamp;
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 5));
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false),
|
||||
success: false,
|
||||
failureDecrement: 0.1,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
final paths = svc.getRecentPaths(pubKey);
|
||||
expect(paths.first.timestamp, equals(successTimestamp));
|
||||
});
|
||||
|
||||
test('success clamps at maxWeight', () async {
|
||||
final pubKey = _hex('w002');
|
||||
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 4.8);
|
||||
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false),
|
||||
success: true,
|
||||
successIncrement: 0.5,
|
||||
maxWeight: 5.0,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
final paths = svc.getRecentPaths(pubKey);
|
||||
expect(paths.first.routeWeight, closeTo(5.0, 0.001));
|
||||
});
|
||||
|
||||
test('failure decrements weight', () async {
|
||||
final pubKey = _hex('w003');
|
||||
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 2.0);
|
||||
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false),
|
||||
success: false,
|
||||
failureDecrement: 0.5,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
final paths = svc.getRecentPaths(pubKey);
|
||||
expect(paths.first.routeWeight, closeTo(1.5, 0.001));
|
||||
});
|
||||
|
||||
test('failure to 0 removes the path', () async {
|
||||
final pubKey = _hex('w004');
|
||||
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 0.3);
|
||||
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false),
|
||||
success: false,
|
||||
failureDecrement: 0.5, // 0.3 - 0.5 = -0.2 → remove
|
||||
);
|
||||
await _flush();
|
||||
|
||||
final paths = svc.getRecentPaths(pubKey);
|
||||
expect(
|
||||
paths.any((p) => p.pathBytes.length == 1 && p.pathBytes[0] == 0x01),
|
||||
isFalse,
|
||||
reason: 'path with weight <= 0 should have been removed',
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'flood result does not affect path records, updates floodStats',
|
||||
() async {
|
||||
final pubKey = _hex('w005');
|
||||
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 1.0);
|
||||
|
||||
final pathsBefore = svc.getRecentPaths(pubKey);
|
||||
final weightBefore = pathsBefore.first.routeWeight;
|
||||
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [], hopCount: -1, useFlood: true),
|
||||
success: true,
|
||||
tripTimeMs: 1234,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
// Path records should be unchanged.
|
||||
final pathsAfter = svc.getRecentPaths(pubKey);
|
||||
expect(pathsAfter.first.routeWeight, equals(weightBefore));
|
||||
|
||||
// Flood stats should be updated.
|
||||
final stats = svc.getFloodStats(pubKey);
|
||||
expect(stats, isNotNull);
|
||||
expect(stats!.successCount, equals(1));
|
||||
expect(stats.lastTripTimeMs, equals(1234));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Group 4: handlePathUpdated
|
||||
// -------------------------------------------------------------------------
|
||||
group('handlePathUpdated', () {
|
||||
test(
|
||||
'pathLength >= 0 with path bytes → records path using pathLength',
|
||||
() async {
|
||||
final pubKey = _hex('h001');
|
||||
final contact = _makeContact(
|
||||
publicKeyHex: pubKey,
|
||||
pathLength: 3,
|
||||
path: [0x01, 0x02, 0x03],
|
||||
);
|
||||
|
||||
svc.handlePathUpdated(contact);
|
||||
await _flush();
|
||||
|
||||
final paths = svc.getRecentPaths(pubKey);
|
||||
expect(paths, isNotEmpty);
|
||||
expect(paths.first.hopCount, equals(3));
|
||||
expect(paths.first.pathBytes, equals([0x01, 0x02, 0x03]));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'pathLength < 0 with path bytes → records path using path.length as hopCount',
|
||||
() async {
|
||||
final pubKey = _hex('h002');
|
||||
final contact = _makeContact(
|
||||
publicKeyHex: pubKey,
|
||||
pathLength: -1, // flood indicator from firmware
|
||||
path: [0xAA, 0xBB],
|
||||
);
|
||||
|
||||
svc.handlePathUpdated(contact);
|
||||
await _flush();
|
||||
|
||||
final paths = svc.getRecentPaths(pubKey);
|
||||
expect(paths, isNotEmpty);
|
||||
// hopCount should equal path.length (2), not pathLength (-1).
|
||||
expect(paths.first.hopCount, equals(2));
|
||||
expect(paths.first.pathBytes, equals([0xAA, 0xBB]));
|
||||
},
|
||||
);
|
||||
|
||||
test('pathLength < 0 with empty path → skipped (returns early)', () async {
|
||||
final pubKey = _hex('h003');
|
||||
final contact = _makeContact(
|
||||
publicKeyHex: pubKey,
|
||||
pathLength: -1,
|
||||
path: [],
|
||||
);
|
||||
|
||||
svc.handlePathUpdated(contact);
|
||||
await _flush();
|
||||
|
||||
// Nothing should have been recorded.
|
||||
final paths = svc.getRecentPaths(pubKey);
|
||||
expect(paths, isEmpty);
|
||||
});
|
||||
|
||||
test('initialWeight is applied to the new record', () async {
|
||||
final pubKey = _hex('h004');
|
||||
final contact = _makeContact(
|
||||
publicKeyHex: pubKey,
|
||||
pathLength: 1,
|
||||
path: [0x55],
|
||||
);
|
||||
|
||||
svc.handlePathUpdated(contact, initialWeight: 2.5);
|
||||
await _flush();
|
||||
|
||||
final paths = svc.getRecentPaths(pubKey);
|
||||
expect(paths.first.routeWeight, closeTo(2.5, 0.001));
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Group 5: recordFloodPathAttribution
|
||||
// -------------------------------------------------------------------------
|
||||
group('recordFloodPathAttribution', () {
|
||||
test('credits existing path with success increment', () async {
|
||||
final pubKey = _hex('fa01');
|
||||
await _seed(
|
||||
svc,
|
||||
pubKey,
|
||||
pathBytes: [0x01, 0x02],
|
||||
hopCount: 2,
|
||||
weight: 1.0,
|
||||
);
|
||||
|
||||
svc.recordFloodPathAttribution(
|
||||
contactPubKeyHex: pubKey,
|
||||
pathBytes: [0x01, 0x02],
|
||||
hopCount: 2,
|
||||
tripTimeMs: 3000,
|
||||
successIncrement: 0.5,
|
||||
maxWeight: 5.0,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
final paths = svc.getRecentPaths(pubKey);
|
||||
final credited = paths.firstWhere(
|
||||
(p) => p.pathBytes.length == 2 && p.pathBytes[0] == 0x01,
|
||||
);
|
||||
expect(credited.routeWeight, closeTo(1.5, 0.001));
|
||||
expect(credited.successCount, equals(1));
|
||||
expect(credited.tripTimeMs, equals(3000));
|
||||
});
|
||||
|
||||
test('creates new path record when path is unknown', () async {
|
||||
final pubKey = _hex('fa02');
|
||||
// Seed with a different path so the cache is warm.
|
||||
await _seed(svc, pubKey, pathBytes: [0xAA], hopCount: 1, weight: 1.0);
|
||||
|
||||
svc.recordFloodPathAttribution(
|
||||
contactPubKeyHex: pubKey,
|
||||
pathBytes: [0xBB, 0xCC],
|
||||
hopCount: 2,
|
||||
tripTimeMs: 2000,
|
||||
successIncrement: 0.5,
|
||||
maxWeight: 5.0,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
final paths = svc.getRecentPaths(pubKey);
|
||||
final newPath = paths.firstWhere(
|
||||
(p) => p.pathBytes.length == 2 && p.pathBytes[0] == 0xBB,
|
||||
);
|
||||
// New path: weight = 1.0 (default) + 0.5 = 1.5
|
||||
expect(newPath.routeWeight, closeTo(1.5, 0.001));
|
||||
expect(newPath.successCount, equals(1));
|
||||
expect(newPath.wasFloodDiscovery, isTrue);
|
||||
});
|
||||
|
||||
test('clamps weight at maxWeight', () async {
|
||||
final pubKey = _hex('fa03');
|
||||
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 4.8);
|
||||
|
||||
svc.recordFloodPathAttribution(
|
||||
contactPubKeyHex: pubKey,
|
||||
pathBytes: [0x01],
|
||||
hopCount: 1,
|
||||
successIncrement: 0.5,
|
||||
maxWeight: 5.0,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
final paths = svc.getRecentPaths(pubKey);
|
||||
expect(paths.first.routeWeight, closeTo(5.0, 0.001));
|
||||
});
|
||||
|
||||
test('ignores empty pathBytes', () async {
|
||||
final pubKey = _hex('fa04');
|
||||
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 1.0);
|
||||
|
||||
final pathsBefore = svc.getRecentPaths(pubKey);
|
||||
final weightBefore = pathsBefore.first.routeWeight;
|
||||
|
||||
svc.recordFloodPathAttribution(
|
||||
contactPubKeyHex: pubKey,
|
||||
pathBytes: [],
|
||||
hopCount: 0,
|
||||
successIncrement: 0.5,
|
||||
maxWeight: 5.0,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
// Existing path should be untouched.
|
||||
final pathsAfter = svc.getRecentPaths(pubKey);
|
||||
expect(pathsAfter.first.routeWeight, equals(weightBefore));
|
||||
});
|
||||
|
||||
test('ignores negative hopCount (flood indicator)', () async {
|
||||
final pubKey = _hex('fa05');
|
||||
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 1.0);
|
||||
|
||||
final pathsBefore = svc.getRecentPaths(pubKey);
|
||||
final weightBefore = pathsBefore.first.routeWeight;
|
||||
|
||||
svc.recordFloodPathAttribution(
|
||||
contactPubKeyHex: pubKey,
|
||||
pathBytes: [0x01],
|
||||
hopCount: -1,
|
||||
successIncrement: 0.5,
|
||||
maxWeight: 5.0,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
final pathsAfter = svc.getRecentPaths(pubKey);
|
||||
expect(pathsAfter.first.routeWeight, equals(weightBefore));
|
||||
});
|
||||
|
||||
test('flood stats still recorded independently', () async {
|
||||
final pubKey = _hex('fa06');
|
||||
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 1.0);
|
||||
|
||||
// Record a flood success (this updates flood stats).
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [], hopCount: -1, useFlood: true),
|
||||
success: true,
|
||||
tripTimeMs: 5000,
|
||||
);
|
||||
|
||||
// Then attribute the flood success to a path.
|
||||
svc.recordFloodPathAttribution(
|
||||
contactPubKeyHex: pubKey,
|
||||
pathBytes: [0x01],
|
||||
hopCount: 1,
|
||||
tripTimeMs: 5000,
|
||||
successIncrement: 0.5,
|
||||
maxWeight: 5.0,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
// Both flood stats and path attribution should exist.
|
||||
final stats = svc.getFloodStats(pubKey);
|
||||
expect(stats, isNotNull);
|
||||
expect(stats!.successCount, equals(1));
|
||||
|
||||
final paths = svc.getRecentPaths(pubKey);
|
||||
expect(paths.first.routeWeight, closeTo(1.5, 0.001));
|
||||
});
|
||||
});
|
||||
}
|
||||
628
test/services/retry_and_protocol_test.dart
Normal file
628
test/services/retry_and_protocol_test.dart
Normal file
|
|
@ -0,0 +1,628 @@
|
|||
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.
|
||||
Uint8List _manualAckHash(
|
||||
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);
|
||||
return Uint8List.fromList(hash.bytes.sublist(0, 4));
|
||||
}
|
||||
|
||||
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 is exactly 4 bytes long', () {
|
||||
final hash = MessageRetryService.computeExpectedAckHash(
|
||||
fixedTs,
|
||||
0,
|
||||
fixedText,
|
||||
fixedKey,
|
||||
);
|
||||
expect(hash.length, equals(4));
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
final hex = hash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue