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

@@ -232,6 +232,26 @@
"description": "Kroatische Sprachoption"
},
"german": "Deutsch",
"@german": {
"description": "Deutsche Sprachoption"
},
"spanish": "Spanisch",
"@spanish": {
"description": "Spanische Sprachoption"
},
"french": "Französisch",
"@french": {
"description": "Französische Sprachoption"
},
"italian": "Italienisch",
"@italian": {
"description": "Italienische Sprachoption"
},
"locationBroadcasting": "Standortübertragung",
"@locationBroadcasting": {
"description": "Überschrift des Standorteinstellungsbereichs"
@@ -639,6 +659,24 @@
"description": "Beschreibung für dauerhafte Raumspeicherung"
},
"drawingsSentToPublicChannel": "{count} Kartenzeichnung{plural} an öffentlichen Kanal gesendet",
"@drawingsSentToPublicChannel": {
"description": "Systemnachricht, wenn Zeichnungen an den öffentlichen Kanal gesendet werden",
"placeholders": {
"count": {"type": "int"},
"plural": {"type": "String"}
}
},
"drawingsSharedToPublicChannel": "{success}/{total} Zeichnungen mit öffentlichem Kanal geteilt",
"@drawingsSharedToPublicChannel": {
"description": "Snackbar-Nachricht, die die Erfolgsanzahl für geteilte Zeichnungen im öffentlichen Kanal anzeigt",
"placeholders": {
"success": {"type": "int"},
"total": {"type": "int"}
}
},
"notConnectedToDevice": "Nicht mit Gerät verbunden",
"@notConnectedToDevice": {
"description": "Fehlermeldung, wenn das Gerät nicht für Direktnachrichten verbunden ist"
@@ -1715,6 +1753,11 @@
"description": "Zustellungsstatus: fehlgeschlagen"
},
"broadcast": "Broadcast",
"@broadcast": {
"description": "Zustellstatus für Kanalnachrichten (noch keine Echos)"
},
"sarMarkerFoundPerson": "Person gefunden",
"@sarMarkerFoundPerson": {
"description": "SAR-Markierungstyp: Person gefunden"

View File

@@ -232,6 +232,26 @@
"description": "Croatian language option"
},
"german": "German",
"@german": {
"description": "German language option"
},
"spanish": "Spanish",
"@spanish": {
"description": "Spanish language option"
},
"french": "French",
"@french": {
"description": "French language option"
},
"italian": "Italian",
"@italian": {
"description": "Italian language option"
},
"locationBroadcasting": "Location Broadcasting",
"@locationBroadcasting": {
"description": "Location settings section header"
@@ -639,6 +659,24 @@
"description": "Description for room storage permanence"
},
"drawingsSentToPublicChannel": "Sent {count} map drawing{plural} to Public Channel",
"@drawingsSentToPublicChannel": {
"description": "System message when drawings are sent to public channel",
"placeholders": {
"count": {"type": "int"},
"plural": {"type": "String"}
}
},
"drawingsSharedToPublicChannel": "Shared {success}/{total} drawings to Public Channel",
"@drawingsSharedToPublicChannel": {
"description": "Snackbar message showing success count for drawings shared to public channel",
"placeholders": {
"success": {"type": "int"},
"total": {"type": "int"}
}
},
"notConnectedToDevice": "Not connected to device",
"@notConnectedToDevice": {
"description": "Error message when device is not connected for direct messaging"
@@ -1715,6 +1753,11 @@
"description": "Delivery status: failed"
},
"broadcast": "Broadcast",
"@broadcast": {
"description": "Delivery status for channel messages (no echoes yet)"
},
"sarMarkerFoundPerson": "Found Person",
"@sarMarkerFoundPerson": {
"description": "SAR marker type: found person"

View File

@@ -232,6 +232,26 @@
"description": "Opción de idioma croata"
},
"german": "Alemán",
"@german": {
"description": "Opción de idioma alemán"
},
"spanish": "Español",
"@spanish": {
"description": "Opción de idioma español"
},
"french": "Francés",
"@french": {
"description": "Opción de idioma francés"
},
"italian": "Italiano",
"@italian": {
"description": "Opción de idioma italiano"
},
"locationBroadcasting": "Difusión de ubicación",
"@locationBroadcasting": {
"description": "Encabezado de la sección de configuración de ubicación"
@@ -639,6 +659,24 @@
"description": "Descripción de la permanencia del almacenamiento en la sala"
},
"drawingsSentToPublicChannel": "{count} dibujo{plural} de mapa enviado al Canal Público",
"@drawingsSentToPublicChannel": {
"description": "Mensaje del sistema cuando se envían dibujos al canal público",
"placeholders": {
"count": {"type": "int"},
"plural": {"type": "String"}
}
},
"drawingsSharedToPublicChannel": "{success}/{total} dibujos compartidos al Canal Público",
"@drawingsSharedToPublicChannel": {
"description": "Mensaje de snackbar mostrando el recuento de éxitos para dibujos compartidos al canal público",
"placeholders": {
"success": {"type": "int"},
"total": {"type": "int"}
}
},
"notConnectedToDevice": "No conectado al dispositivo",
"@notConnectedToDevice": {
"description": "Mensaje de error cuando el dispositivo no está conectado para mensajería directa"
@@ -1715,6 +1753,11 @@
"description": "Estado de entrega: fallido"
},
"broadcast": "Difusión",
"@broadcast": {
"description": "Estado de entrega para mensajes de canal (sin ecos aún)"
},
"sarMarkerFoundPerson": "Persona encontrada",
"@sarMarkerFoundPerson": {
"description": "Tipo de marcador SAR: persona encontrada"

View File

@@ -232,6 +232,26 @@
"description": "Option de langue croate"
},
"german": "Allemand",
"@german": {
"description": "Option de langue allemande"
},
"spanish": "Espagnol",
"@spanish": {
"description": "Option de langue espagnole"
},
"french": "Français",
"@french": {
"description": "Option de langue française"
},
"italian": "Italien",
"@italian": {
"description": "Option de langue italienne"
},
"locationBroadcasting": "Diffusion de position",
"@locationBroadcasting": {
"description": "En-tête de section des paramètres de localisation"
@@ -639,6 +659,24 @@
"description": "Description de la permanence du stockage dans le salon"
},
"drawingsSentToPublicChannel": "{count} dessin{plural} de carte envoyé au Canal Public",
"@drawingsSentToPublicChannel": {
"description": "Message système lors de l'envoi de dessins au canal public",
"placeholders": {
"count": {"type": "int"},
"plural": {"type": "String"}
}
},
"drawingsSharedToPublicChannel": "{success}/{total} dessins partagés sur le Canal Public",
"@drawingsSharedToPublicChannel": {
"description": "Message de snackbar montrant le nombre de succès pour les dessins partagés sur le canal public",
"placeholders": {
"success": {"type": "int"},
"total": {"type": "int"}
}
},
"notConnectedToDevice": "Non connecté à l'appareil",
"@notConnectedToDevice": {
"description": "Message d'erreur lorsque l'appareil n'est pas connecté pour la messagerie directe"
@@ -1715,6 +1753,11 @@
"description": "État de livraison : échec"
},
"broadcast": "Diffusion",
"@broadcast": {
"description": "État de livraison pour les messages de canal (pas encore d'échos)"
},
"sarMarkerFoundPerson": "Personne trouvée",
"@sarMarkerFoundPerson": {
"description": "Type de marqueur SAR : personne trouvée"

View File

@@ -85,6 +85,14 @@
"croatian": "Hrvatski",
"german": "Njemački",
"spanish": "Španjolski",
"french": "Francuski",
"italian": "Talijanski",
"locationBroadcasting": "Emitiranje lokacije",
"autoLocationTracking": "Automatsko praćenje lokacije",
@@ -223,6 +231,10 @@
"storedPermanently": "Trajno pohranjeno u sobi",
"drawingsSentToPublicChannel": "Poslano {count} crtež{plural} na javni kanal",
"drawingsSharedToPublicChannel": "Podijeljeno {success}/{total} crteža na javni kanal",
"notConnectedToDevice": "Nije povezano s uređajem",
"directMessage": "Izravna poruka",
@@ -285,7 +297,7 @@
"direct": "Izravno",
"flood": "Poplava",
"flood": "Preplavljanje",
"admin": "Administrator",
@@ -297,13 +309,13 @@
"pingingDirect": "Pingiranje {name} (izravno putem puta)...",
"pingingFlood": "Pingiranje {name} (poplava - nema puta)...",
"pingingFlood": "Pingiranje {name} (preplavljanje - nema puta)...",
"directPingTimeout": "Istek izravnog pinga - ponovni pokušaj {name} s poplavom...",
"directPingTimeout": "Istek izravnog pinga - ponovni pokušaj {name} s preplavljanjem...",
"pingSuccessful": "Ping uspješan prema {name}{fallback}",
"viaFloodingFallback": " (putem rezervnog plavljenja)",
"viaFloodingFallback": " (putem rezervnog preplavljanja)",
"pingFailed": "Ping neuspješan prema {name} - nije primljen odgovor",
@@ -543,6 +555,8 @@
"failed": "Neuspjelo",
"broadcast": "Emitirano",
"messageSentToPublicChannel": "Poruka poslana na javni kanal",
"pleaseSelectRoomToSendSar": "Molimo odaberite sobu za slanje SAR markera",

View File

@@ -232,6 +232,26 @@
"description": "Opzione lingua croata"
},
"german": "Tedesco",
"@german": {
"description": "Opzione lingua tedesca"
},
"spanish": "Spagnolo",
"@spanish": {
"description": "Opzione lingua spagnola"
},
"french": "Francese",
"@french": {
"description": "Opzione lingua francese"
},
"italian": "Italiano",
"@italian": {
"description": "Opzione lingua italiana"
},
"locationBroadcasting": "Trasmissione Posizione",
"@locationBroadcasting": {
"description": "Intestazione sezione impostazioni posizione"
@@ -639,6 +659,24 @@
"description": "Descrizione per la permanenza dell'archiviazione nella stanza"
},
"drawingsSentToPublicChannel": "{count} disegno{plural} mappa inviato al Canale Pubblico",
"@drawingsSentToPublicChannel": {
"description": "Messaggio di sistema quando i disegni vengono inviati al canale pubblico",
"placeholders": {
"count": {"type": "int"},
"plural": {"type": "String"}
}
},
"drawingsSharedToPublicChannel": "{success}/{total} disegni condivisi sul Canale Pubblico",
"@drawingsSharedToPublicChannel": {
"description": "Messaggio snackbar che mostra il conteggio dei successi per i disegni condivisi sul canale pubblico",
"placeholders": {
"success": {"type": "int"},
"total": {"type": "int"}
}
},
"notConnectedToDevice": "Non connesso al dispositivo",
"@notConnectedToDevice": {
"description": "Messaggio di errore quando il dispositivo non è connesso per la messaggistica diretta"
@@ -1715,6 +1753,11 @@
"description": "Stato consegna: fallito"
},
"broadcast": "Trasmissione",
"@broadcast": {
"description": "Stato di consegna per messaggi di canale (nessun eco ancora)"
},
"sarMarkerFoundPerson": "Persona Trovata",
"@sarMarkerFoundPerson": {
"description": "Tipo marcatore SAR: persona trovata"

View File

@@ -360,6 +360,30 @@ abstract class AppLocalizations {
/// **'Croatian'**
String get croatian;
/// German language option
///
/// In en, this message translates to:
/// **'German'**
String get german;
/// Spanish language option
///
/// In en, this message translates to:
/// **'Spanish'**
String get spanish;
/// French language option
///
/// In en, this message translates to:
/// **'French'**
String get french;
/// Italian language option
///
/// In en, this message translates to:
/// **'Italian'**
String get italian;
/// Location settings section header
///
/// In en, this message translates to:
@@ -779,6 +803,18 @@ abstract class AppLocalizations {
/// **'Stored permanently in room'**
String get storedPermanently;
/// System message when drawings are sent to public channel
///
/// In en, this message translates to:
/// **'Sent {count} map drawing{plural} to Public Channel'**
String drawingsSentToPublicChannel(int count, String plural);
/// Snackbar message showing success count for drawings shared to public channel
///
/// In en, this message translates to:
/// **'Shared {success}/{total} drawings to Public Channel'**
String drawingsSharedToPublicChannel(int success, int total);
/// Error message when device is not connected for direct messaging
///
/// In en, this message translates to:
@@ -1835,6 +1871,12 @@ abstract class AppLocalizations {
/// **'Failed'**
String get failed;
/// Delivery status for channel messages (no echoes yet)
///
/// In en, this message translates to:
/// **'Broadcast'**
String get broadcast;
/// SAR marker type: found person
///
/// In en, this message translates to:

View File

@@ -145,6 +145,18 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get croatian => 'Kroatisch';
@override
String get german => 'Deutsch';
@override
String get spanish => 'Spanisch';
@override
String get french => 'Französisch';
@override
String get italian => 'Italienisch';
@override
String get locationBroadcasting => 'Standortübertragung';
@@ -388,6 +400,16 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get storedPermanently => 'Dauerhaft im Raum gespeichert';
@override
String drawingsSentToPublicChannel(int count, String plural) {
return '$count Kartenzeichnung$plural an öffentlichen Kanal gesendet';
}
@override
String drawingsSharedToPublicChannel(int success, int total) {
return '$success/$total Zeichnungen mit öffentlichem Kanal geteilt';
}
@override
String get notConnectedToDevice => 'Nicht mit Gerät verbunden';
@@ -1009,6 +1031,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get failed => 'Fehlgeschlagen';
@override
String get broadcast => 'Broadcast';
@override
String get sarMarkerFoundPerson => 'Person gefunden';

View File

@@ -144,6 +144,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get croatian => 'Croatian';
@override
String get german => 'German';
@override
String get spanish => 'Spanish';
@override
String get french => 'French';
@override
String get italian => 'Italian';
@override
String get locationBroadcasting => 'Location Broadcasting';
@@ -385,6 +397,16 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get storedPermanently => 'Stored permanently in room';
@override
String drawingsSentToPublicChannel(int count, String plural) {
return 'Sent $count map drawing$plural to Public Channel';
}
@override
String drawingsSharedToPublicChannel(int success, int total) {
return 'Shared $success/$total drawings to Public Channel';
}
@override
String get notConnectedToDevice => 'Not connected to device';
@@ -999,6 +1021,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get failed => 'Failed';
@override
String get broadcast => 'Broadcast';
@override
String get sarMarkerFoundPerson => 'Found Person';

View File

@@ -144,6 +144,18 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get croatian => 'Croata';
@override
String get german => 'Alemán';
@override
String get spanish => 'Español';
@override
String get french => 'Francés';
@override
String get italian => 'Italiano';
@override
String get locationBroadcasting => 'Difusión de ubicación';
@@ -386,6 +398,16 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get storedPermanently => 'Almacenado permanentemente en la sala';
@override
String drawingsSentToPublicChannel(int count, String plural) {
return '$count dibujo$plural de mapa enviado al Canal Público';
}
@override
String drawingsSharedToPublicChannel(int success, int total) {
return '$success/$total dibujos compartidos al Canal Público';
}
@override
String get notConnectedToDevice => 'No conectado al dispositivo';
@@ -1005,6 +1027,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get failed => 'Fallido';
@override
String get broadcast => 'Difusión';
@override
String get sarMarkerFoundPerson => 'Persona encontrada';

View File

@@ -145,6 +145,18 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get croatian => 'Croate';
@override
String get german => 'Allemand';
@override
String get spanish => 'Espagnol';
@override
String get french => 'Français';
@override
String get italian => 'Italien';
@override
String get locationBroadcasting => 'Diffusion de position';
@@ -389,6 +401,16 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get storedPermanently => 'Stocké de manière permanente dans le salon';
@override
String drawingsSentToPublicChannel(int count, String plural) {
return '$count dessin$plural de carte envoyé au Canal Public';
}
@override
String drawingsSharedToPublicChannel(int success, int total) {
return '$success/$total dessins partagés sur le Canal Public';
}
@override
String get notConnectedToDevice => 'Non connecté à l\'appareil';
@@ -1010,6 +1032,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get failed => 'Échec';
@override
String get broadcast => 'Diffusion';
@override
String get sarMarkerFoundPerson => 'Personne trouvée';

View File

@@ -144,6 +144,18 @@ class AppLocalizationsHr extends AppLocalizations {
@override
String get croatian => 'Hrvatski';
@override
String get german => 'Njemački';
@override
String get spanish => 'Španjolski';
@override
String get french => 'Francuski';
@override
String get italian => 'Talijanski';
@override
String get locationBroadcasting => 'Emitiranje lokacije';
@@ -385,6 +397,16 @@ class AppLocalizationsHr extends AppLocalizations {
@override
String get storedPermanently => 'Trajno pohranjeno u sobi';
@override
String drawingsSentToPublicChannel(int count, String plural) {
return 'Poslano $count crtež$plural na javni kanal';
}
@override
String drawingsSharedToPublicChannel(int success, int total) {
return 'Podijeljeno $success/$total crteža na javni kanal';
}
@override
String get notConnectedToDevice => 'Nije povezano s uređajem';
@@ -494,7 +516,7 @@ class AppLocalizationsHr extends AppLocalizations {
String get direct => 'Izravno';
@override
String get flood => 'Poplava';
String get flood => 'Preplavljanje';
@override
String get admin => 'Administrator';
@@ -515,12 +537,12 @@ class AppLocalizationsHr extends AppLocalizations {
@override
String pingingFlood(String name) {
return 'Pingiranje $name (poplava - nema puta)...';
return 'Pingiranje $name (preplavljanje - nema puta)...';
}
@override
String directPingTimeout(String name) {
return 'Istek izravnog pinga - ponovni pokušaj $name s poplavom...';
return 'Istek izravnog pinga - ponovni pokušaj $name s preplavljanjem...';
}
@override
@@ -529,7 +551,7 @@ class AppLocalizationsHr extends AppLocalizations {
}
@override
String get viaFloodingFallback => ' (putem rezervnog plavljenja)';
String get viaFloodingFallback => ' (putem rezervnog preplavljanja)';
@override
String pingFailed(String name) {
@@ -1001,6 +1023,9 @@ class AppLocalizationsHr extends AppLocalizations {
@override
String get failed => 'Neuspjelo';
@override
String get broadcast => 'Emitirano';
@override
String get sarMarkerFoundPerson => 'Pronađena osoba';

View File

@@ -144,6 +144,18 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get croatian => 'Croato';
@override
String get german => 'Tedesco';
@override
String get spanish => 'Spagnolo';
@override
String get french => 'Francese';
@override
String get italian => 'Italiano';
@override
String get locationBroadcasting => 'Trasmissione Posizione';
@@ -387,6 +399,16 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get storedPermanently => 'Archiviato permanentemente nella stanza';
@override
String drawingsSentToPublicChannel(int count, String plural) {
return '$count disegno$plural mappa inviato al Canale Pubblico';
}
@override
String drawingsSharedToPublicChannel(int success, int total) {
return '$success/$total disegni condivisi sul Canale Pubblico';
}
@override
String get notConnectedToDevice => 'Non connesso al dispositivo';
@@ -1007,6 +1029,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get failed => 'Fallito';
@override
String get broadcast => 'Trasmissione';
@override
String get sarMarkerFoundPerson => 'Persona Trovata';

View File

@@ -144,6 +144,18 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get croatian => 'Hrvaščina';
@override
String get german => 'Nemščina';
@override
String get spanish => 'Španščina';
@override
String get french => 'Francoščina';
@override
String get italian => 'Italijanščina';
@override
String get locationBroadcasting => 'Oddajanje lokacije';
@@ -385,6 +397,16 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get storedPermanently => 'Trajno shranjeno v sobi';
@override
String drawingsSentToPublicChannel(int count, String plural) {
return 'Poslano $count risb$plural na javni kanal';
}
@override
String drawingsSharedToPublicChannel(int success, int total) {
return 'Deljeno $success/$total risb na javni kanal';
}
@override
String get notConnectedToDevice => 'Ni povezano z napravo';
@@ -494,7 +516,7 @@ class AppLocalizationsSl extends AppLocalizations {
String get direct => 'Neposredno';
@override
String get flood => 'Poplava';
String get flood => 'Razpršitev';
@override
String get admin => 'Administrator';
@@ -515,12 +537,12 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String pingingFlood(String name) {
return 'Pinganje $name (poplava - brez poti)...';
return 'Pinganje $name (razpršitev - brez poti)...';
}
@override
String directPingTimeout(String name) {
return 'Časovna omejitev neposrednega pinga - ponovni poskus $name s poplavo...';
return 'Časovna omejitev neposrednega pinga - ponovni poskus $name z razprševanjem...';
}
@override
@@ -529,7 +551,7 @@ class AppLocalizationsSl extends AppLocalizations {
}
@override
String get viaFloodingFallback => ' (preko rezervnega poplavljanja)';
String get viaFloodingFallback => ' (preko rezervnega razprševanja)';
@override
String pingFailed(String name) {
@@ -1001,6 +1023,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get failed => 'Neuspešno';
@override
String get broadcast => 'Oddajano';
@override
String get sarMarkerFoundPerson => 'Najdena oseba';

View File

@@ -85,6 +85,14 @@
"croatian": "Hrvaščina",
"german": "Nemščina",
"spanish": "Španščina",
"french": "Francoščina",
"italian": "Italijanščina",
"locationBroadcasting": "Oddajanje lokacije",
"autoLocationTracking": "Samodejno sledenje lokaciji",
@@ -223,6 +231,10 @@
"storedPermanently": "Trajno shranjeno v sobi",
"drawingsSentToPublicChannel": "Poslano {count} risb{plural} na javni kanal",
"drawingsSharedToPublicChannel": "Deljeno {success}/{total} risb na javni kanal",
"notConnectedToDevice": "Ni povezano z napravo",
"directMessage": "Neposredno sporočilo",
@@ -285,7 +297,7 @@
"direct": "Neposredno",
"flood": "Poplava",
"flood": "Razpršitev",
"admin": "Administrator",
@@ -297,13 +309,13 @@
"pingingDirect": "Pinganje {name} (neposredno preko poti)...",
"pingingFlood": "Pinganje {name} (poplava - brez poti)...",
"pingingFlood": "Pinganje {name} (razpršitev - brez poti)...",
"directPingTimeout": "Časovna omejitev neposrednega pinga - ponovni poskus {name} s poplavo...",
"directPingTimeout": "Časovna omejitev neposrednega pinga - ponovni poskus {name} z razprševanjem...",
"pingSuccessful": "Ping uspešen do {name}{fallback}",
"viaFloodingFallback": " (preko rezervnega poplavljanja)",
"viaFloodingFallback": " (preko rezervnega razprševanja)",
"pingFailed": "Ping neuspešen do {name} - odgovor ni prejet",
@@ -543,6 +555,8 @@
"failed": "Neuspešno",
"broadcast": "Oddajano",
"messageSentToPublicChannel": "Sporočilo poslano na javni kanal",
"pleaseSelectRoomToSendSar": "Prosimo, izberite sobo za pošiljanje SAR označevalca",

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)';
}
}

View File

@@ -208,6 +208,12 @@ class AppProvider with ChangeNotifier {
messagesProvider.markMessageDelivered(ackCode, roundTripTimeMs);
};
// When an echo is detected for a public channel message (PUSH_CODE_LOG_RX_DATA matched)
connectionProvider.onMessageEchoDetected = (messageId, echoCount, snrRaw, rssiDbm) {
debugPrint('🔊 [AppProvider] Echo detected - Message: $messageId, Count: $echoCount');
messagesProvider.handleMessageEcho(messageId, echoCount, snrRaw, rssiDbm);
};
// Wire up MessagesProvider's sendMessageCallback for retry logic
messagesProvider.sendMessageCallback = ({
required contactPublicKey,
@@ -284,7 +290,7 @@ class AppProvider with ChangeNotifier {
try {
// Get all room contacts (excluding Public Channel)
final rooms = contactsProvider.rooms
.where((room) => room.advName != 'Public Channel')
.where((room) => !room.isPublicChannel)
.toList();
if (rooms.isEmpty) {

View File

@@ -133,6 +133,8 @@ class ConnectionProvider with ChangeNotifier {
Function(String messageId, int expectedAckTag, int suggestedTimeoutMs)?
onMessageSent;
Function(int ackCode, int roundTripTimeMs)? onMessageDelivered;
Function(String messageId, int echoCount, int snrRaw, int rssiDbm)?
onMessageEchoDetected;
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
// Track pending send operations for auto-recovery
@@ -381,6 +383,13 @@ class ConnectionProvider with ChangeNotifier {
onMessageDelivered?.call(ackCode, roundTripTimeMs);
};
_bleService.onMessageEchoDetected = (messageId, echoCount, snrRaw, rssiDbm) {
print(
'🔊 [Provider] Echo detected - Message: $messageId, Count: $echoCount',
);
onMessageEchoDetected?.call(messageId, echoCount, snrRaw, rssiDbm);
};
_bleService.onStatusResponse = (publicKeyPrefix, statusData) {
print('📥 [Provider] Status response received from node');
print(
@@ -774,8 +783,16 @@ class ConnectionProvider with ChangeNotifier {
}
try {
debugPrint('📨 [ConnectionProvider] sendChannelMessage called:');
debugPrint(' Channel: $channelIdx');
debugPrint(' Text: $text');
debugPrint(' MessageID: $messageId');
await _bleService.sendChannelMessage(channelIdx: channelIdx, text: text);
debugPrint('✅ [ConnectionProvider] BLE send completed');
debugPrint(' Checking messageId: ${messageId != null ? "Present ($messageId)" : "NULL"}');
// Channel messages are ephemeral (not persisted) - mark as "sent" immediately
// They don't have ACK/TAG mechanism like direct messages
if (messageId != null) {
@@ -783,6 +800,12 @@ class ConnectionProvider with ChangeNotifier {
print(' Message ID: $messageId');
print(' onMessageSent callback exists: ${onMessageSent != null}');
// Track for echo detection
// The BLE handler will capture the packet via LOG_RX_DATA and associate it
debugPrint(' Calling trackSentChannelMessage...');
_bleService.trackSentChannelMessage(messageId);
debugPrint(' trackSentChannelMessage completed');
// Small delay to ensure the message is in the MessagesProvider list
// before we try to mark it as sent
await Future.delayed(const Duration(milliseconds: 50));

View File

@@ -602,6 +602,36 @@ class MessagesProvider with ChangeNotifier {
}
}
/// Handle echo detection for public channel messages
void handleMessageEcho(String messageId, int echoCount, int snrRaw, int rssiDbm) {
print('🔊 [MessagesProvider] handleMessageEcho called');
print(' Message ID: $messageId');
print(' Echo count: $echoCount');
print(' SNR: ${(snrRaw.toSigned(8) / 4.0).toStringAsFixed(2)} dB');
print(' RSSI: ${rssiDbm.toSigned(8)} dBm');
// Find the message
final index = _messages.indexWhere((m) => m.id == messageId);
if (index != -1) {
final message = _messages[index];
print(' ✅ Found message: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...');
// Update echo count
final updatedMessage = message.copyWith(
echoCount: echoCount,
firstEchoAt: message.firstEchoAt ?? DateTime.now(),
);
_messages[index] = updatedMessage;
print(' Updated echo count to: $echoCount');
_persistMessages();
notifyListeners();
print(' ✅ Echo update complete, UI notified');
} else {
print(' ⚠️ Message not found in messages list');
}
}
/// Update message status to delivered with RTT
void markMessageDelivered(int ackCode, int roundTripTimeMs) {
print('🔍 [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms');

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,

View File

@@ -8,6 +8,17 @@ extension MessageLocalization on Message {
String getLocalizedDeliveryStatus(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
// For channel messages, show echo count instead of delivery status
if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) {
if (echoCount == 0) {
return l10n.broadcast; // "Broadcast (no echoes yet)"
} else if (echoCount == 1) {
return 'Rebroadcast by 1 node';
} else {
return 'Rebroadcast by $echoCount nodes';
}
}
switch (deliveryStatus) {
case MessageDeliveryStatus.sending:
return l10n.sending;

View File

@@ -719,7 +719,7 @@ class ContactTile extends StatelessWidget {
),
],
// Room Login button for room contacts (except Public Channel)
if (contact.type == ContactType.room && contact.advName != 'Public Channel') ...[
if (contact.type == ContactType.room && !contact.isPublicChannel) ...[
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
@@ -739,7 +739,7 @@ class ContactTile extends StatelessWidget {
),
],
// Delete Contact button (for all contact types except Public Channel)
if (contact.advName != 'Public Channel') ...[
if (!contact.isPublicChannel) ...[
const SizedBox(height: 12),
SizedBox(
width: double.infinity,

View File

@@ -482,15 +482,16 @@ class DrawingToolbar extends StatelessWidget {
// Add informational message to chat
final messagesProvider = Provider.of<MessagesProvider>(context, listen: false);
final l10n = AppLocalizations.of(context)!;
messagesProvider.logSystemMessage(
text: '📤 Sent ${drawings.length} map drawing${drawings.length > 1 ? 's' : ''} to Public Channel',
text: '📤 ${l10n.drawingsSentToPublicChannel(drawings.length, drawings.length > 1 ? 's' : '')}',
level: 'info',
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Shared $successCount/${drawings.length} drawings to Public Channel',
l10n.drawingsSharedToPublicChannel(successCount, drawings.length),
),
backgroundColor: successCount == drawings.length
? Colors.green