Add localization support for German, Spanish, French, and Italian

- Updated localization files for French, Croatian, Italian, Slovenian to include translations for German, Spanish, French, and Italian.
- Added new methods for handling drawing messages sent to the public channel in multiple languages.
- Enhanced the Contact model to identify public channels using a dedicated method.
- Implemented echo detection for public channel messages, including tracking and reporting of echoes.
- Updated BLE response handling to support echo detection and tracking of sent messages.
- Modified UI components to reflect new localization strings and echo statuses.
This commit is contained in:
Janez T
2025-10-18 22:39:26 +02:00
parent da03592980
commit 52b2f4ede1
30 changed files with 1971 additions and 37 deletions

View File

@@ -6,6 +6,7 @@ import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import '../../models/contact.dart';
import '../../models/message.dart';
import '../../models/ble_packet_log.dart';
import '../../models/sent_message_tracker.dart';
import '../buffer_reader.dart';
import '../meshcore_constants.dart';
import '../meshcore_opcode_names.dart';
@@ -33,6 +34,7 @@ typedef OnBatteryAndStorageCallback = void Function(int millivolts, int? usedKb,
typedef OnErrorCallback = void Function(String error, {int? errorCode});
typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey);
typedef OnChannelInfoCallback = void Function(int channelIdx, String channelName);
typedef OnMessageEchoDetectedCallback = void Function(String messageId, int echoCount, int snrRaw, int rssiDbm);
/// Processes incoming responses from the BLE device
class BleResponseHandler {
@@ -45,6 +47,11 @@ class BleResponseHandler {
// Reference to command queue for completing pending commands
BleCommandQueue? _commandQueue;
// Echo detection for public channel messages
final Map<String, SentMessageTracker> _sentMessageTrackers = {};
static const int _maxTrackers = 100;
static const Duration _trackerTTL = Duration(minutes: 5);
// Callbacks
OnContactCallback? onContactReceived;
OnContactsCompleteCallback? onContactsComplete;
@@ -66,6 +73,7 @@ class BleResponseHandler {
OnErrorCallback? onError;
OnContactNotFoundCallback? onContactNotFound;
OnChannelInfoCallback? onChannelInfoReceived;
OnMessageEchoDetectedCallback? onMessageEchoDetected;
VoidCallback? onRxActivity;
// Track the last command that was sent, so we can retry if it fails with ERR_CODE_NOT_FOUND
@@ -436,6 +444,12 @@ class BleResponseHandler {
final entropy = uniqueBytes / rawPacketData.length;
final isLikelyEncrypted = entropy > 0.7;
// First, try to associate this packet with a recently sent message (within 2s)
_associatePacketWithSentMessage(rawPacketData);
// Then, check if this packet matches any sent message (echo detection)
_checkForEcho(rawPacketData, snrRaw, rssiDbm);
// Create decoded info for packet log (includes SNR and RSSI)
final logRxDataInfo = LogRxDataInfo(
entropy: entropy,
@@ -465,6 +479,246 @@ class BleResponseHandler {
}
}
/// Simple hash function for packet identification (replaces SHA256)
String _simplePacketHash(Uint8List packet) {
// Use a simple hash based on packet length and first/last bytes
// This is sufficient for short-lived echo detection (5 min TTL)
if (packet.isEmpty) return '0';
int hash = packet.length;
// Mix in bytes from start, middle, and end
for (int i = 0; i < packet.length && i < 8; i++) {
hash = ((hash << 5) - hash) + packet[i];
hash = hash & 0xFFFFFFFF; // Keep 32-bit
}
if (packet.length > 16) {
for (int i = packet.length ~/ 2; i < packet.length ~/ 2 + 8 && i < packet.length; i++) {
hash = ((hash << 5) - hash) + packet[i];
hash = hash & 0xFFFFFFFF;
}
}
if (packet.length > 8) {
for (int i = packet.length - 8; i < packet.length; i++) {
hash = ((hash << 5) - hash) + packet[i];
hash = hash & 0xFFFFFFFF;
}
}
return hash.toRadixString(16).padLeft(8, '0');
}
/// Check if received packet is an echo of a sent message
void _checkForEcho(Uint8List rawPacket, int snrRaw, int rssiDbm) {
try {
// Need at least header + path_len
if (rawPacket.length < 2) return;
final header = rawPacket[0];
final payloadType = (header >> 2) & 0x0F;
if (payloadType != 0x05) return; // Only track GRP_TXT
final pathLen = rawPacket[1];
if (pathLen == 0 || rawPacket.length < 2 + pathLen) return;
// Extract path for unique echo tracking
final path = rawPacket.sublist(2, 2 + pathLen);
final pathSignature = path.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
// Check if our node hash is in the path (meaning this is our message being rebroadcast)
final containsOurHash = _ourNodeHash != null && path.contains(_ourNodeHash!);
if (!containsOurHash) {
// This packet doesn't have our hash in the path, so it's not our message
return;
}
// Extract encrypted payload
final payloadStart = 2 + pathLen;
final encryptedPayload = rawPacket.sublist(payloadStart);
final payloadHash = _simplePacketHash(encryptedPayload);
// Check if we have a matching sent message (by payload hash)
final tracker = _sentMessageTrackers[payloadHash];
if (tracker != null && !tracker.isExpired) {
// Check if this is a NEW path (different from already seen paths)
if (!tracker.uniqueEchoPaths.contains(pathSignature)) {
// New echo detected via different path!
tracker.uniqueEchoPaths.add(pathSignature);
tracker.echoCount++;
tracker.echoTimestamps.add(DateTime.now());
print(' 🔊 [Echo] New echo detected!');
print(' Message: ${tracker.messageId}');
print(' Path: $pathSignature');
print(' Total echoes: ${tracker.echoCount}');
print(' Unique paths: ${tracker.uniqueEchoPaths.length}');
// Notify callback
onMessageEchoDetected?.call(tracker.messageId, tracker.echoCount, snrRaw, rssiDbm);
} else {
print(' ♻️ [Echo] Duplicate path (already counted): $pathSignature');
}
}
// Cleanup expired trackers
_cleanupExpiredTrackers();
} catch (e) {
print(' ⚠️ [Echo] Error checking for echo: $e');
}
}
/// Track a sent public channel message for echo detection
///
/// NEW STRATEGY: Since firmware doesn't log our own transmissions,
/// we track ANY GRP_TXT packets that arrive shortly after sending.
/// The first packet with matching encrypted payload is likely our message,
/// and subsequent packets with the same payload are echoes.
void trackSentMessage(String messageId, Uint8List? rawPacket) {
try {
final now = DateTime.now();
final tracker = SentMessageTracker(
messageId: messageId,
packetHashHex: 'pending', // Will be filled when we capture ANY packet
rawPacket: null,
sentTime: now,
expiryTime: now.add(_trackerTTL),
);
// Store by message ID temporarily
_sentMessageTrackers[messageId] = tracker;
print(' 📤 [Echo] Tracking message $messageId (will match any GRP_TXT within 2000ms)');
print(' 📊 [Echo] Total trackers: ${_sentMessageTrackers.length}');
// Cleanup if too many trackers
if (_sentMessageTrackers.length > _maxTrackers) {
_cleanupOldestTrackers();
}
} catch (e) {
print(' ⚠️ [Echo] Error tracking sent message: $e');
}
}
// Store our node hash (first byte of our public key) for sender identification
int? _ourNodeHash;
/// Set our node hash for packet identification
void setOurNodeHash(int nodeHash) {
_ourNodeHash = nodeHash;
print(' 🔑 [Echo] Our node hash set to: 0x${nodeHash.toRadixString(16).padLeft(2, '0')}');
print(' [Echo] Will track packets containing our hash in the path');
}
/// Associate a captured packet with a sent message
///
/// NEW STRATEGY: Firmware doesn't log our own transmissions, only echoes!
/// So we capture the FIRST GRP_TXT packet after sending (likely an echo),
/// then count additional instances of the same packet payload.
///
/// Packet structure for GRP_TXT:
/// [0] = header (route type + payload type + version)
/// [1] = path_len
/// [2] = path[0] = sender's node hash
/// [3+] = rest of path + encrypted payload
void _associatePacketWithSentMessage(Uint8List rawPacket) {
try {
// Need at least 3 bytes: header + path_len + first path byte
if (rawPacket.length < 3) {
return;
}
// Check if this is a GRP_TXT packet (payload type = 0x05)
final header = rawPacket[0];
final payloadType = (header >> 2) & 0x0F;
if (payloadType != 0x05) { // Not a group message
return;
}
final pathLen = rawPacket[1];
if (pathLen == 0) {
return;
}
final now = DateTime.now();
// Extract the path from the packet for unique echo tracking
final path = rawPacket.sublist(2, 2 + pathLen);
final pathSignature = path.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
// Check if our node hash is in the path (meaning this is our message being rebroadcast)
final containsOurHash = _ourNodeHash != null && path.contains(_ourNodeHash!);
if (!containsOurHash) {
// This packet doesn't have our hash in the path, so it's not our message
return;
}
print(' ✅ [Echo] Packet contains our hash (0x${_ourNodeHash!.toRadixString(16).padLeft(2, '0')}) in path: $pathSignature');
// Extract encrypted payload (everything after path)
final payloadStart = 2 + pathLen;
final encryptedPayload = rawPacket.sublist(payloadStart);
// Hash only the encrypted payload to identify the same message
final payloadHash = _simplePacketHash(encryptedPayload);
// Find pending trackers (within 2000ms window)
for (final entry in _sentMessageTrackers.entries.toList()) {
final tracker = entry.value;
if (tracker.packetHashHex != 'pending') continue;
final timeSinceSent = now.difference(tracker.sentTime);
if (timeSinceSent.inMilliseconds > 2000) continue; // Outside window
// This is the FIRST packet we see after sending - associate it!
// Remove old entry by message ID
_sentMessageTrackers.remove(entry.key);
// Create updated tracker stored by payload hash
final updatedTracker = SentMessageTracker(
messageId: tracker.messageId,
packetHashHex: payloadHash, // Use payload hash to identify message
rawPacket: rawPacket,
sentTime: tracker.sentTime,
expiryTime: tracker.expiryTime,
echoCount: 1, // This first packet counts as an echo
uniqueEchoPaths: {pathSignature}, // Track unique paths
echoTimestamps: [now],
);
_sentMessageTrackers[payloadHash] = updatedTracker;
print(' 📦 [Echo] Captured packet for tracking!');
print(' Message ID: ${tracker.messageId}');
print(' Path: $pathSignature');
print(' Time delta: ${timeSinceSent.inMilliseconds}ms');
print(' Payload hash: $payloadHash');
print(' Echo count: 1 (first detection)');
// Notify immediately that we have 1 echo
onMessageEchoDetected?.call(tracker.messageId, 1, 0, 0);
break; // Only associate with first pending tracker
}
} catch (e) {
print(' ⚠️ [Echo] Error associating packet: $e');
}
}
/// Remove expired trackers
void _cleanupExpiredTrackers() {
_sentMessageTrackers.removeWhere((key, tracker) => tracker.isExpired);
}
/// Remove oldest trackers when limit exceeded
void _cleanupOldestTrackers() {
if (_sentMessageTrackers.length <= _maxTrackers) return;
// Sort by sent time and remove oldest
final sortedEntries = _sentMessageTrackers.entries.toList()
..sort((a, b) => a.value.sentTime.compareTo(b.value.sentTime));
final toRemove = sortedEntries.take(_sentMessageTrackers.length - _maxTrackers);
for (final entry in toRemove) {
_sentMessageTrackers.remove(entry.key);
}
print(' 🧹 [Echo] Cleaned up ${toRemove.length} old trackers');
}
/// Handle NewAdvert push
void _handleNewAdvert(BufferReader reader) {
try {

View File

@@ -10,6 +10,10 @@ class LocalePreferences {
Locale('en'), // English
Locale('sl'), // Slovenian
Locale('hr'), // Croatian
Locale('de'), // German
Locale('es'), // Spanish
Locale('fr'), // French
Locale('it'), // Italian
];
/// Get the saved locale or return null to use system locale
@@ -49,6 +53,14 @@ class LocalePreferences {
return 'Slovenščina';
case 'hr':
return 'Hrvatski';
case 'de':
return 'Deutsch';
case 'es':
return 'Español';
case 'fr':
return 'Français';
case 'it':
return 'Italiano';
default:
return locale.languageCode;
}
@@ -63,6 +75,14 @@ class LocalePreferences {
return 'Slovenščina';
case 'hr':
return 'Hrvatski';
case 'de':
return 'Deutsch';
case 'es':
return 'Español';
case 'fr':
return 'Français';
case 'it':
return 'Italiano';
default:
return locale.languageCode;
}

View File

@@ -26,6 +26,7 @@ typedef OnAdvertReceivedCallback = void Function(Uint8List publicKey);
typedef OnPathUpdatedCallback = void Function(Uint8List publicKey);
typedef OnMessageSentCallback = void Function(int expectedAckTag, int suggestedTimeoutMs, bool isFloodMode);
typedef OnMessageDeliveredCallback = void Function(int ackCode, int roundTripTimeMs);
typedef OnMessageEchoDetectedCallback = void Function(String messageId, int echoCount, int snrRaw, int rssiDbm);
typedef OnStatusResponseCallback = void Function(Uint8List publicKeyPrefix, Uint8List statusData);
typedef OnBinaryResponseCallback = void Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData);
typedef OnBatteryAndStorageCallback = void Function(int millivolts, int? usedKb, int? totalKb);
@@ -61,6 +62,7 @@ class MeshCoreBleService {
OnPathUpdatedCallback? onPathUpdated;
OnMessageSentCallback? onMessageSent;
OnMessageDeliveredCallback? onMessageDelivered;
OnMessageEchoDetectedCallback? onMessageEchoDetected;
OnStatusResponseCallback? onStatusResponse;
OnBinaryResponseCallback? onBinaryResponse;
OnBatteryAndStorageCallback? onBatteryAndStorage;
@@ -116,6 +118,13 @@ class MeshCoreBleService {
onTelemetryReceived?.call(publicKey, lppData);
};
_responseHandler.onSelfInfoReceived = (selfInfo) {
// Extract our node hash (first byte of public key) for echo detection
if (selfInfo['publicKey'] != null) {
final publicKey = selfInfo['publicKey'] as Uint8List;
if (publicKey.isNotEmpty) {
_responseHandler.setOurNodeHash(publicKey[0]);
}
}
onSelfInfoReceived?.call(selfInfo);
};
_responseHandler.onDeviceInfoReceived = (deviceInfo) {
@@ -145,6 +154,9 @@ class MeshCoreBleService {
_responseHandler.onMessageDelivered = (ackCode, roundTripTimeMs) {
onMessageDelivered?.call(ackCode, roundTripTimeMs);
};
_responseHandler.onMessageEchoDetected = (messageId, echoCount, snrRaw, rssiDbm) {
onMessageEchoDetected?.call(messageId, echoCount, snrRaw, rssiDbm);
};
_responseHandler.onStatusResponse = (publicKeyPrefix, statusData) {
onStatusResponse?.call(publicKeyPrefix, statusData);
};
@@ -297,6 +309,12 @@ class MeshCoreBleService {
}
/// Send flood-mode text message to channel
/// Track a sent channel message for echo detection
void trackSentChannelMessage(String messageId) {
debugPrint('🔵 [MeshCoreBleService] trackSentChannelMessage called for: $messageId');
_responseHandler.trackSentMessage(messageId, null);
}
Future<void> sendChannelMessage({
required int channelIdx,
required String text,