diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index e0a8029..c8028e0 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 7211992..d00d49a 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -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 loadContactCache() async { @@ -753,22 +768,61 @@ class MeshCoreConnector extends ChangeNotifier { } } - void _sendMessageDirect( + Future _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.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.delayed(const Duration(milliseconds: 200)); + } + debugPrint( + 'Radio quiet wait exceeded ${_radioQuietMaxWaitMs}ms, sending anyway', + ); + } + + Future _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().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 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 startScan({ @@ -1730,47 +1831,43 @@ class MeshCoreConnector extends ChangeNotifier { Future 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.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().firstWhere( (c) => c?.publicKeyHex == contactPubKeyHex, orElse: () => null, ); final isRoomServer = contact?.type == advTypeRoom; + ReactionHelper.applyReaction( + 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 messages, + ReactionInfo reactionInfo, + Contact contact, + ) { + final isRoomServer = contact.type == advTypeRoom; + + ReactionHelper.applyReaction( + 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().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().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.from(msg.reactions); - currentReactions[reactionInfo.emoji] = - (currentReactions[reactionInfo.emoji] ?? 0) + 1; - - messages[i] = msg.copyWith(reactions: currentReactions); + if (msgHash == reactionInfo.targetHash) { + final statuses = Map.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().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 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.from(msg.reactions); - currentReactions[reactionInfo.emoji] = - (currentReactions[reactionInfo.emoji] ?? 0) + 1; - - messages[i] = msg.copyWith(reactions: currentReactions); + ReactionHelper.applyReaction( + 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 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 { diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index dc9a9f5..1a0ada1 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -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); diff --git a/lib/helpers/link_handler.dart b/lib/helpers/link_handler.dart index 7a032ef..57a5e59 100644 --- a/lib/helpers/link_handler.dart +++ b/lib/helpers/link_handler.dart @@ -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 handleLinkTap(BuildContext context, String url) async { // Show confirmation dialog final shouldOpen = await showDialog( diff --git a/lib/helpers/path_helper.dart b/lib/helpers/path_helper.dart new file mode 100644 index 0000000..fe51d63 --- /dev/null +++ b/lib/helpers/path_helper.dart @@ -0,0 +1,31 @@ +import '../models/contact.dart'; +import '../connector/meshcore_protocol.dart'; + +class PathHelper { + static String formatPathHex(List pathBytes) { + return pathBytes + .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) + .join(','); + } + + static String resolvePathNames( + List pathBytes, + List 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 '); + } +} diff --git a/lib/helpers/reaction_helper.dart b/lib/helpers/reaction_helper.dart index 88138d6..90733c3 100644 --- a/lib/helpers/reaction_helper.dart +++ b/lib/helpers/reaction_helper.dart @@ -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({ + required List messages, + required ReactionInfo reactionInfo, + required int Function(T) getTimestampSecs, + required String? Function(T) getSenderName, + required String Function(T) getMessageText, + required Map Function(T) getReactions, + required bool Function(T) shouldSkip, + required void Function(int index, Map 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.from(getReactions(msg)); + currentReactions[reactionInfo.emoji] = + (currentReactions[reactionInfo.emoji] ?? 0) + 1; + updateMessage(i, currentReactions); + return true; + } + } + return false; + } + static List? _cachedEmojis; /// Combined list of all reaction emojis in fixed order. diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index ca27f8c..b8ea08f 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -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}" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index bd4aed5..681cff6 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -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}" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5c95e60..3942afb 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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})", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 085b0c8..4a83680 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -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}" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index b7617bb..1d684bb 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -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}" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 728eaac..55a1054 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -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}" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index b38c08f..84b5432 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 96b67d8..2821617 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -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 => 'Батерия'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index dcbcd3f..337915e 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -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'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 01127c6..1e4e5b0 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index fac431e..657d556 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -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'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 6932437..7aa7ebe 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -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'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 68c2af3..02c5937 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -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'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 4031ddf..9e51164 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -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'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 6378e74..176c17e 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -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'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 908ad96..a51e1b0 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -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'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 67011fb..7a6998f 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -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 => 'Батарея'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 4f033f9..ae6c956 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -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'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index e7c48f6..96501cd 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -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'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 6ccea2f..a834230 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -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'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 788c9d1..7db1cc7 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -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 => 'Батарея'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index be7eeb0..dc1a17e 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -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 => '电池'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 648d711..3caea31 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -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}" } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index f4f3ac7..c6e3fc4 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -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}" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index dd1698c..e7e2ec6 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -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}" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index ea75aca..92a3800 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -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}" } diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index 636556e..75a7c7d 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -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}" } diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index dfc5a69..5ab4736 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -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}" } diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 6a8d801..644b43b 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -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}" } diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index a50bd78..249fd3b 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -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}" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 54d1e3c..1d4ed30 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -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}" } diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index fc84851..8ee904d 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -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 diff --git a/lib/models/channel_message.dart b/lib/models/channel_message.dart index 2418871..b0af3eb 100644 --- a/lib/models/channel_message.dart +++ b/lib/models/channel_message.dart @@ -36,6 +36,7 @@ class ChannelMessage { final List 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? pathVariants, this.channelIndex, String? messageId, + this.packetHash, this.replyToMessageId, this.replyToSenderName, this.replyToText, @@ -79,6 +81,7 @@ class ChannelMessage { int? pathLength, Uint8List? pathBytes, List? 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, diff --git a/lib/models/contact.dart b/lib/models/contact.dart index c047622..71467b1 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -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, diff --git a/lib/models/message.dart b/lib/models/message.dart index 4f42d96..6f6ed88 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -23,6 +23,7 @@ class Message { final int? pathLength; final Uint8List pathBytes; final Map reactions; + final Map reactionStatuses; final Uint8List fourByteRoomContactKey; Message({ @@ -43,9 +44,11 @@ class Message { Uint8List? pathBytes, Uint8List? fourByteRoomContactKey, Map? reactions, + Map? 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? reactions, + Map? 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, ); diff --git a/lib/models/path_history.dart b/lib/models/path_history.dart index 5e3ea1f..ff2d226 100644 --- a/lib/models/path_history.dart +++ b/lib/models/path_history.dart @@ -1,11 +1,12 @@ class PathRecord { final int hopCount; final int tripTimeMs; - final DateTime timestamp; + final DateTime? timestamp; final bool wasFloodDiscovery; final List 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, ); } } diff --git a/lib/models/path_selection.dart b/lib/models/path_selection.dart index 65f2f27..cdb3d72 100644 --- a/lib/models/path_selection.dart +++ b/lib/models/path_selection.dart @@ -1,3 +1,9 @@ +import 'dart:typed_data'; + +import 'contact.dart'; + +const int recentAttemptDiversityWindow = 2; + class PathSelection { final List 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, + ); +} diff --git a/lib/screens/app_settings_screen.dart b/lib/screens/app_settings_screen.dart index a2c920e..7e0980e 100644 --- a/lib/screens/app_settings_screen.dart +++ b/lib/screens/app_settings_screen.dart @@ -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()), + ), + ], + ), + ), + ], ], ), ); diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 4e3743d..20110e1 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -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 { ], 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 { 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 { 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 { ], ); - if (!isOutgoing) { + if (!isOutgoing && !PlatformInfo.isDesktop) { return _SwipeReplyBubble( maxSwipeOffset: maxSwipeOffset, replySwipeThreshold: replySwipeThreshold, @@ -1112,6 +1109,15 @@ class _ChannelChatScreenState extends State { _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( diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 98694be..51d2453 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -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 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, + ), ), ), ); diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 5209b41..ace82b5 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -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 { 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 { ); } - 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 { return; } - final formattedPath = pathBytes - .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) - .join(','); + final connector = context.read(); + 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 { _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(), diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 23844fb..011e6d0 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -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, ); } diff --git a/lib/screens/discovery_screen.dart b/lib/screens/discovery_screen.dart index 7f065aa..4e7c6e8 100644 --- a/lib/screens/discovery_screen.dart +++ b/lib/screens/discovery_screen.dart @@ -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 { 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 { onLongPress: () => _showContactContextMenu(contact, connector), ); + if (PlatformInfo.isDesktop) { + return GestureDetector( + onSecondaryTapUp: (_) => + _showContactContextMenu(contact, connector), + child: tile, + ); + } + return tile; }, ), ), diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index df16a59..6aaebf0 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -617,19 +617,6 @@ class _MapScreenState extends State { 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 { 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 { 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 { 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) { diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart index 88c1f81..e6697f4 100644 --- a/lib/services/app_settings_service.dart +++ b/lib/services/app_settings_service.dart @@ -120,6 +120,30 @@ class AppSettingsService extends ChangeNotifier { await updateSettings(_settings.copyWith(autoRouteRotationEnabled: value)); } + Future setMaxRouteWeight(double value) async { + await updateSettings(_settings.copyWith(maxRouteWeight: value)); + } + + Future setInitialRouteWeight(double value) async { + await updateSettings(_settings.copyWith(initialRouteWeight: value)); + } + + Future setRouteWeightSuccessIncrement(double value) async { + await updateSettings( + _settings.copyWith(routeWeightSuccessIncrement: value), + ); + } + + Future setRouteWeightFailureDecrement(double value) async { + await updateSettings( + _settings.copyWith(routeWeightFailureDecrement: value), + ); + } + + Future setMaxMessageRetries(int value) async { + await updateSettings(_settings.copyWith(maxMessageRetries: value)); + } + Future setThemeMode(String value) async { await updateSettings(_settings.copyWith(themeMode: value)); } diff --git a/lib/services/message_retry_service.dart b/lib/services/message_retry_service.dart index b66ba51..2f10511 100644 --- a/lib/services/message_retry_service.dart +++ b/lib/services/message_retry_service.dart @@ -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 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 _timeoutTimers = {}; final Map _pendingMessages = {}; final Map _pendingContacts = {}; - final Map _pendingPathSelections = {}; - final Map _ackHashToMessageId = - {}; // ackHashHex → messageId + timestamp for O(1) lookup - final Map> _expectedAckHashes = - {}; // Track all expected ACKs for retries (for history) - final List<_AckHistoryEntry> _ackHistory = - []; // Rolling buffer of recent ACK hashes - final Map> _pendingMessageQueuePerContact = - {}; // contactPubKeyHex → FIFO queue of messageIds (DEPRECATED - will be removed) - final Map> _sendQueue = - {}; // contactPubKeyHex → ordered list of messageIds awaiting send - final Set _activeMessages = - {}; // messageIds currently in-flight (sent/retrying) - final Set _resolvedMessages = - {}; // messageIds already resolved (prevents double _onMessageResolved) - final Map _expectedHashToMessageId = - {}; // expectedAckHashHex → messageId (for matching RESP_CODE_SENT by hash) + final Map> _attemptPathHistory = {}; + final Map _ackHashToMessageId = {}; + final Map> _expectedAckHashes = {}; + final List<_AckHistoryEntry> _ackHistory = []; + final Map> _sendQueue = {}; + final Set _activeMessages = {}; + final Set _resolvedMessages = {}; + final Map _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 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.from( + _attemptPathHistory[message.messageId] ?? const [], + ); + 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 _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 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 = []; 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(); diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 62d3796..b367e0e 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -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 _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 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, ), diff --git a/lib/services/path_history_service.dart b/lib/services/path_history_service.dart index 569fada..809f867 100644 --- a/lib/services/path_history_service.dart +++ b/lib/services/path_history_service.dart @@ -9,6 +9,8 @@ class PathHistoryService extends ChangeNotifier { final Map _cache = {}; final Map _autoRotationIndex = {}; final Map _floodStats = {}; + final Set _pendingLoads = {}; + final Map> _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 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 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 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 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 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.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 clearPathHistory(String contactPubKeyHex) async { _cache.remove(contactPubKeyHex); _cacheAccessOrder.remove(contactPubKeyHex); @@ -322,26 +465,81 @@ class PathHistoryService extends ChangeNotifier { final ranked = List.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 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 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 a, List 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 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; diff --git a/lib/storage/channel_message_store.dart b/lib/storage/channel_message_store.dart index 7bf44bd..ddb42f6 100644 --- a/lib/storage/channel_message_store.dart +++ b/lib/storage/channel_message_store.dart @@ -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?, diff --git a/lib/storage/message_store.dart b/lib/storage/message_store.dart index 9a39e3f..44d3621 100644 --- a/lib/storage/message_store.dart +++ b/lib/storage/message_store.dart @@ -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?)?.map( + (key, value) => MapEntry(key, MessageStatus.values[value as int]), + ) ?? + {}, fourByteRoomContactKey: json['fourByteRoomContactKey'] != null ? Uint8List.fromList( base64Decode(json['fourByteRoomContactKey'] as String), diff --git a/lib/widgets/path_management_dialog.dart b/lib/widgets/path_management_dialog.dart index 861241b..f667256 100644 --- a/lib/widgets/path_management_dialog.dart +++ b/lib/widgets/path_management_dialog.dart @@ -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(); + 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, diff --git a/test/helpers/path_helper_test.dart b/test/helpers/path_helper_test.dart new file mode 100644 index 0000000..38abf2c --- /dev/null +++ b/test/helpers/path_helper_test.dart @@ -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')); + }); +} diff --git a/test/models/model_changes_test.dart b/test/models/model_changes_test.dart new file mode 100644 index 0000000..165b91d --- /dev/null +++ b/test/models/model_changes_test.dart @@ -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 = {}; + 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)); + }); + }); +} diff --git a/test/services/path_history_service_test.dart b/test/services/path_history_service_test.dart new file mode 100644 index 0000000..561bad3 --- /dev/null +++ b/test/services/path_history_service_test.dart @@ -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 _store = {}; + + @override + Future savePathHistory( + String contactPubKeyHex, + ContactPathHistory history, + ) async { + _store[contactPubKeyHex] = history; + } + + @override + Future loadPathHistory(String contactPubKeyHex) async { + return _store[contactPubKeyHex]; + } + + @override + Future 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 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 _flush() async { + await Future.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 _seed( + PathHistoryService svc, + String pubKeyHex, { + List 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.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)); + }); + }); +} diff --git a/test/services/retry_and_protocol_test.dart b/test/services/retry_and_protocol_test.dart new file mode 100644 index 0000000..b58da45 --- /dev/null +++ b/test/services/retry_and_protocol_test.dart @@ -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 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 = {}; + 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); + }); + }); +}