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

@@ -214,10 +214,14 @@ class Contact {
return advName.substring(emoji.length).trim();
}
/// Check if this contact is the Public Channel (all-zeros public key)
bool get isPublicChannel =>
publicKeyHex == '0000000000000000000000000000000000000000000000000000000000000000';
/// Get localized display name (for Public Channel and other special contacts)
String getLocalizedDisplayName(BuildContext context) {
// Check if this is the Public Channel (all-zeros public key)
if (publicKeyHex == '0000000000000000000000000000000000000000000000000000000000000000') {
if (isPublicChannel) {
return AppLocalizations.of(context)!.publicChannel;
}
// For all other contacts, use the regular display name
@@ -226,24 +230,28 @@ class Contact {
/// Check if contact has a learned routing path
/// When true, messages will use direct routing. When false, messages will use flood mode.
bool get hasPath => outPathLen > 0 && outPathLen <= 64;
/// outPathLen: -1 = unknown/not learned, 0 = direct (zero hops), 1+ = multi-hop path
bool get hasPath => outPathLen >= 0 && outPathLen <= 64;
/// Get path description for UI display
String get pathDescription {
if (!hasPath) {
// -1 (0xFF) indicates path not learned yet
return 'No path (flood mode)';
}
// outPathLen includes the number of hops in the path
final hops = outPathLen;
if (hops == 1) {
// outPathLen = 0 means direct connection with zero hops
// outPathLen >= 1 means path with N hops
if (outPathLen == 0) {
return 'Direct (0 hops)';
} else if (hops <= 3) {
return 'Good path (${hops - 1} hop${hops - 1 > 1 ? 's' : ''})';
} else if (hops <= 5) {
return 'Medium path (${hops - 1} hops)';
} else if (outPathLen == 1) {
return 'Direct (1 hop)';
} else if (outPathLen <= 3) {
return 'Good path ($outPathLen hops)';
} else if (outPathLen <= 5) {
return 'Medium path ($outPathLen hops)';
} else {
return 'Long path (${hops - 1} hops)';
return 'Long path ($outPathLen hops)';
}
}
@@ -251,11 +259,11 @@ class Contact {
/// -1 means no path (will use flood mode)
int get pathQuality {
if (!hasPath) return -1;
if (outPathLen == 1) return 5; // Direct connection (0 hops)
if (outPathLen <= 2) return 4; // 1 hop
if (outPathLen <= 3) return 3; // 2 hops
if (outPathLen <= 4) return 2; // 3 hops
if (outPathLen <= 5) return 1; // 4 hops
if (outPathLen == 0) return 5; // Direct connection (0 hops)
if (outPathLen == 1) return 4; // 1 hop
if (outPathLen <= 2) return 3; // 2 hops
if (outPathLen <= 3) return 2; // 3 hops
if (outPathLen <= 4) return 1; // 4 hops
return 0; // 5+ hops
}

View File

@@ -72,6 +72,10 @@ class Message {
// Read status tracking
final bool isRead; // Whether message has been read by user
// Echo detection for public channel messages
final int echoCount; // Number of times message was detected being rebroadcast
final DateTime? firstEchoAt; // When first echo was detected
Message({
required this.id,
required this.messageType,
@@ -97,6 +101,8 @@ class Message {
this.lastRetryAt,
this.usedFloodFallback = false,
this.isRead = false,
this.echoCount = 0,
this.firstEchoAt,
});
/// Get sender public key as hex string
@@ -176,8 +182,26 @@ class Message {
);
}
/// Get echo status text for channel messages
String get echoStatusText {
if (!isChannelMessage) return '';
if (echoCount == 0) {
return 'Broadcast (no echoes)';
} else if (echoCount == 1) {
return 'Rebroadcast by 1 node';
} else {
return 'Rebroadcast by $echoCount nodes';
}
}
/// Get friendly delivery status description
String get deliveryStatusText {
// For channel messages, show echo status instead
if (isChannelMessage && isSentMessage) {
return echoStatusText;
}
switch (deliveryStatus) {
case MessageDeliveryStatus.sending:
if (retryAttempt > 0) {
@@ -263,6 +287,8 @@ class Message {
DateTime? lastRetryAt,
bool? usedFloodFallback,
bool? isRead,
int? echoCount,
DateTime? firstEchoAt,
}) {
return Message(
id: id ?? this.id,
@@ -289,6 +315,8 @@ class Message {
lastRetryAt: lastRetryAt ?? this.lastRetryAt,
usedFloodFallback: usedFloodFallback ?? this.usedFloodFallback,
isRead: isRead ?? this.isRead,
echoCount: echoCount ?? this.echoCount,
firstEchoAt: firstEchoAt ?? this.firstEchoAt,
);
}

View File

@@ -0,0 +1,83 @@
import 'dart:typed_data';
/// Tracks sent public channel messages for echo detection
///
/// When a message is sent to the public channel, it's encrypted with AES128-ECB
/// which is deterministic. When another node receives and rebroadcasts it,
/// the raw packet will be byte-for-byte identical. We can detect these echoes
/// by comparing the raw packet data from PUSH_CODE_LOG_RX_DATA (0x88) against
/// packets we've sent.
class SentMessageTracker {
/// Unique identifier for the message (timestamp-based)
final String messageId;
/// SHA256 hash of the encrypted packet for fast O(1) lookup
final String packetHashHex;
/// Original raw encrypted packet bytes (for verification)
final Uint8List? rawPacket;
/// When the message was sent
final DateTime sentTime;
/// When this tracker expires (default: 5 minutes)
final DateTime expiryTime;
/// Number of times we've detected this message being rebroadcast
int echoCount;
/// Unique echo paths detected (SNR/RSSI signatures)
/// Format: "snr_rssi" e.g., "20_-56" means SNR=5.0dB (20/4), RSSI=-56dBm
final Set<String> uniqueEchoPaths;
/// Timestamps when echoes were detected
final List<DateTime> echoTimestamps;
SentMessageTracker({
required this.messageId,
required this.packetHashHex,
this.rawPacket,
required this.sentTime,
required this.expiryTime,
this.echoCount = 0,
Set<String>? uniqueEchoPaths,
List<DateTime>? echoTimestamps,
}) : uniqueEchoPaths = uniqueEchoPaths ?? {},
echoTimestamps = echoTimestamps ?? [];
/// Check if this tracker has expired
bool get isExpired => DateTime.now().isAfter(expiryTime);
/// Time until expiry
Duration get timeUntilExpiry => expiryTime.difference(DateTime.now());
/// Add an echo detection
void addEcho(int snrRaw, int rssiDbm) {
echoCount++;
uniqueEchoPaths.add('${snrRaw}_$rssiDbm');
echoTimestamps.add(DateTime.now());
}
/// Get the SNR in dB from raw value
static double snrRawToDb(int snrRaw) {
return snrRaw.toSigned(8) / 4.0;
}
/// Get formatted echo statistics
String get echoStats {
if (echoCount == 0) return 'No echoes detected';
if (echoCount == 1) return '1 echo from ${uniqueEchoPaths.length} path(s)';
return '$echoCount echoes from ${uniqueEchoPaths.length} path(s)';
}
/// Get average time to first echo
Duration? get timeToFirstEcho {
if (echoTimestamps.isEmpty) return null;
return echoTimestamps.first.difference(sentTime);
}
@override
String toString() {
return 'SentMessageTracker(id=$messageId, echoes=$echoCount, paths=${uniqueEchoPaths.length}, expired=$isExpired)';
}
}