Notification rate limiting (#110)

* Add notification rate limiting with privacy-safe debug logging

- Add batching system to prevent notification storms (3s rate limit, 5s batch window)
- Queue rapid notifications and show batch summaries
- Debug logs show device names for adverts, sender/channel for messages (no content leaks)
- Remove unused _maxBatchSize constant

Context: Added after getting notification-flooded while evaluating RF flood management. The irony.

* Update notification_service.dart

I made a mistake and removed this

* Add l10n support for notification strings

Addresses PR #110 review feedback to use the translations system:
- Add notification strings to app_en.arb (plurals for batch summary)
- Update NotificationService to use lookupAppLocalizations()
- Wire locale from MaterialApp to NotificationService
- Regenerate localization files

New strings added (English only, translations needed):
- notification_activityTitle: "MeshCore Activity"
- notification_messagesCount: "{count} message(s)"
- notification_channelMessagesCount: "{count} channel message(s)"
- notification_newNodesCount: "{count} new node(s)"
- notification_newTypeDiscovered: "New {type} discovered"
- notification_receivedNewMessage: "Received new message"

* Add notification string translations for all supported languages

Translated notification_activityTitle, notification_messagesCount,
notification_channelMessagesCount, notification_newNodesCount,
notification_newTypeDiscovered, and notification_receivedNewMessage
to: bg, de, es, fr, it, nl, pl, pt, ru, sk, sl, sv, uk, zh

Includes proper ICU plural forms for Slavic languages (few/many/other)
and Slovenian dual form.

* Apply dart format to notification_service.dart

---------

Co-authored-by: Winston Lowe <wel97459@gmail.com>
This commit is contained in:
Ryan Malloy 2026-02-08 19:42:15 -07:00 committed by GitHub
parent ea43cf17eb
commit daca42701c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1282 additions and 7 deletions

View file

@ -1569,6 +1569,13 @@
"contacts_zeroHopContactAdvertFailed": "Неуспешно изпращане на контакт.",
"contacts_zeroHopContactAdvertSent": "Изпратен контакт по обява.",
"contacts_contactAdvertCopyFailed": "Копирането на обявата в клипборда не успя.",
"notification_activityTitle": "Активност на MeshCore",
"notification_messagesCount": "{count} {count, plural, =1{съобщение} other{съобщения}}",
"notification_channelMessagesCount": "{count} {count, plural, =1{съобщение в канал} other{съобщения в канали}}",
"notification_newNodesCount": "{count} {count, plural, =1{нов възел} other{нови възли}}",
"notification_newTypeDiscovered": "Открит нов {contactType}",
"notification_receivedNewMessage": "Получено ново съобщение",
"contacts_contactAdvertCopyFailed": "Копирането на обявата в клипборда не успя.",
"settings_gpxExportContactsSubtitle": "Експортира спътници с местоположение в GPX файл.",
"settings_gpxExportRepeatersSubtitle": "Изпраща повторители / roomserver с местоположение в GPX файл.",
"settings_gpxExportAll": "Експортирай всички контакти в GPX",
@ -1585,4 +1592,5 @@
"settings_gpxExportShareText": "Картинни данни изнесени от meshcore-open",
"settings_gpxExportShareSubject": "meshcore-open износ на данни за карта в формат GPX",
"pathTrace_someHopsNoLocation": "Един или повече от хмелите липсва местоположение!"
}

View file

@ -1569,6 +1569,34 @@
"contacts_zeroHopContactAdvertSent": "Kontakt über Anzeige gesendet",
"contacts_contactAdvertCopied": "Anzeige in die Zwischenablage kopiert.",
"contacts_contactAdvertCopyFailed": "Kopieren des Werbeinhalts in die Zwischenablage fehlgeschlagen.",
"notification_activityTitle": "MeshCore Aktivität",
"notification_messagesCount": "{count} {count, plural, =1{Nachricht} other{Nachrichten}}",
"@notification_messagesCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"notification_channelMessagesCount": "{count} {count, plural, =1{Kanalnachricht} other{Kanalnachrichten}}",
"@notification_channelMessagesCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"notification_newNodesCount": "{count} {count, plural, =1{neuer Knoten} other{neue Knoten}}",
"@notification_newNodesCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"notification_newTypeDiscovered": "Neuer {contactType} entdeckt",
"@notification_newTypeDiscovered": {
"placeholders": {
"contactType": {"type": "String"}
}
},
"notification_receivedNewMessage": "Neue Nachricht empfangen",
"contacts_contactAdvertCopyFailed": "Kopieren des Werbeinhalts in die Zwischenablage fehlgeschlagen.",
"settings_gpxExportAll": "Alle Kontakte nach GPX exportieren",
"settings_gpxExportAllSubtitle": "Exportiert alle Kontakte mit einem Standort in eine GPX-Datei.",
"settings_gpxExportRepeaters": "Repeater und Raumserver nach GPX exportieren",
@ -1585,4 +1613,5 @@
"settings_gpxExportShareSubject": "meshcore-open GPX-Kartendaten exportieren",
"settings_gpxExportShareText": "Kartendaten aus meshcore-open exportiert",
"pathTrace_someHopsNoLocation": "Eine oder mehrere der Hopfen fehlen einen Standort!"
}

View file

@ -1346,12 +1346,39 @@
"contacts_contactAdvertCopied": "Advert copied to Clipboard.",
"contacts_contactAdvertCopyFailed": "Copying advert to Clipboard failed.",
"notification_activityTitle": "MeshCore Activity",
"notification_messagesCount": "{count} {count, plural, =1{message} other{messages}}",
"@notification_messagesCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"notification_channelMessagesCount": "{count} {count, plural, =1{channel message} other{channel messages}}",
"@notification_channelMessagesCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"notification_newNodesCount": "{count} {count, plural, =1{new node} other{new nodes}}",
"@notification_newNodesCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"notification_newTypeDiscovered": "New {contactType} discovered",
"@notification_newTypeDiscovered": {
"placeholders": {
"contactType": {"type": "String"}
}
},
"notification_receivedNewMessage": "Received new message",
"settings_gpxExportRepeaters": "Export repeaters / room server to GPX",
"settings_gpxExportRepeatersSubtitle": "Exports repeaters / roomserver with a location to GPX file.",
"settings_gpxExportContacts": "Export companions to GPX",
"settings_gpxExportContactsSubtitle": "Exports companions with a location to GPX file.",
"settings_gpxExportAll": "Export all contacts to GPX",
"settings_gpxExportAllSubtitle": "Exports all contacts with a location to GPX file.",
"settings_gpxExportAllSubtitle": "Exports all contacts with a location to GPX file.",
"settings_gpxExportSuccess": "Successfully exported GPX file.",
"settings_gpxExportNoContacts": "No contacts to export.",
"settings_gpxExportNotAvailable": "Not supported on your device/OS",

View file

@ -1569,6 +1569,34 @@
"contacts_zeroHopContactAdvertSent": "Envió contacto por anuncio.",
"contacts_contactAdvertCopied": "Anuncio copiado al Portapapeles.",
"contacts_contactAdvertCopyFailed": "Copiar anuncio al Portapapeles ha fallado.",
"notification_activityTitle": "Actividad de MeshCore",
"notification_messagesCount": "{count} {count, plural, =1{mensaje} other{mensajes}}",
"@notification_messagesCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"notification_channelMessagesCount": "{count} {count, plural, =1{mensaje de canal} other{mensajes de canal}}",
"@notification_channelMessagesCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"notification_newNodesCount": "{count} {count, plural, =1{nuevo nodo} other{nuevos nodos}}",
"@notification_newNodesCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"notification_newTypeDiscovered": "Nuevo {contactType} descubierto",
"@notification_newTypeDiscovered": {
"placeholders": {
"contactType": {"type": "String"}
}
},
"notification_receivedNewMessage": "Nuevo mensaje recibido",
"contacts_contactAdvertCopyFailed": "Copiar anuncio al Portapapeles ha fallado.",
"settings_gpxExportContactsSubtitle": "Exporta compañeros con una ubicación a archivo GPX.",
"settings_gpxExportRepeaters": "Exportar repetidores / servidor de sala a GPX",
"settings_gpxExportSuccess": "Archivo GPX exportado con éxito.",
@ -1585,4 +1613,5 @@
"settings_gpxExportShareText": "Datos del mapa exportados desde meshcore-open",
"settings_gpxExportShareSubject": "meshcore-open exportación de datos de mapa GPX",
"pathTrace_someHopsNoLocation": "Uno o más de los lúpulos carecen de una ubicación"
}

View file

@ -1569,6 +1569,13 @@
"contacts_contactAdvertCopyFailed": "La copie de l'annonce vers le presse-papiers a échoué.",
"contacts_zeroHopContactAdvertSent": "Envoyer un contact par annonce.",
"contacts_zeroHopContactAdvertFailed": "Échec de l'envoi du contact.",
"notification_activityTitle": "Activité MeshCore",
"notification_messagesCount": "{count} {count, plural, =1{message} other{messages}}",
"notification_channelMessagesCount": "{count} {count, plural, =1{message de canal} other{messages de canal}}",
"notification_newNodesCount": "{count} {count, plural, =1{nouveau nœud} other{nouveaux nœuds}}",
"notification_newTypeDiscovered": "Nouveau {contactType} découvert",
"notification_receivedNewMessage": "Nouveau message reçu",
"contacts_zeroHopContactAdvertFailed": "Échec de l'envoi du contact.",
"settings_gpxExportRepeaters": "Exporter les répéteurs / serveur de salle au format GPX",
"settings_gpxExportRepeatersSubtitle": "Exporte les répéteurs / roomserver avec une localisation vers un fichier GPX.",
"settings_gpxExportNoContacts": "Aucun contact à exporter.",
@ -1585,4 +1592,5 @@
"settings_gpxExportShareText": "Données de carte exportées à partir de meshcore-open",
"settings_gpxExportShareSubject": "meshcore-open exporter les données de carte GPX",
"pathTrace_someHopsNoLocation": "Une ou plusieurs des houblons manquent d'une localisation !"
}

View file

@ -1569,6 +1569,13 @@
"contacts_ShareContactZeroHop": "Condividi contatto tramite annuncio",
"contacts_zeroHopContactAdvertFailed": "Invio del contatto non riuscito.",
"contacts_contactAdvertCopied": "Annuncio copiato negli Appunti.",
"notification_activityTitle": "Attività MeshCore",
"notification_messagesCount": "{count} {count, plural, =1{messaggio} other{messaggi}}",
"notification_channelMessagesCount": "{count} {count, plural, =1{messaggio del canale} other{messaggi del canale}}",
"notification_newNodesCount": "{count} {count, plural, =1{nuovo nodo} other{nuovi nodi}}",
"notification_newTypeDiscovered": "Nuovo {contactType} scoperto",
"notification_receivedNewMessage": "Nuovo messaggio ricevuto",
"contacts_contactAdvertCopied": "Annuncio copiato negli Appunti.",
"settings_gpxExportRepeaters": "Esporta ripetitori / server di stanza in GPX",
"settings_gpxExportContacts": "Esporta compagni in GPX",
"settings_gpxExportSuccess": "Esportazione del file GPX completata con successo.",
@ -1585,4 +1592,5 @@
"settings_gpxExportShareText": "Dati mappa esportati da meshcore-open",
"settings_gpxExportShareSubject": "meshcore-open esportazione dati mappa GPX",
"pathTrace_someHopsNoLocation": "Uno o più dei luppoli mancano di una posizione!"
}

View file

@ -4862,6 +4862,42 @@ abstract class AppLocalizations {
/// **'Copying advert to Clipboard failed.'**
String get contacts_contactAdvertCopyFailed;
/// No description provided for @notification_activityTitle.
///
/// In en, this message translates to:
/// **'MeshCore Activity'**
String get notification_activityTitle;
/// No description provided for @notification_messagesCount.
///
/// In en, this message translates to:
/// **'{count} {count, plural, =1{message} other{messages}}'**
String notification_messagesCount(int count);
/// No description provided for @notification_channelMessagesCount.
///
/// In en, this message translates to:
/// **'{count} {count, plural, =1{channel message} other{channel messages}}'**
String notification_channelMessagesCount(int count);
/// No description provided for @notification_newNodesCount.
///
/// In en, this message translates to:
/// **'{count} {count, plural, =1{new node} other{new nodes}}'**
String notification_newNodesCount(int count);
/// No description provided for @notification_newTypeDiscovered.
///
/// In en, this message translates to:
/// **'New {contactType} discovered'**
String notification_newTypeDiscovered(String contactType);
/// No description provided for @notification_receivedNewMessage.
///
/// In en, this message translates to:
/// **'Received new message'**
String get notification_receivedNewMessage;
/// No description provided for @settings_gpxExportRepeaters.
///
/// In en, this message translates to:

View file

@ -2771,6 +2771,50 @@ class AppLocalizationsBg extends AppLocalizations {
String get contacts_contactAdvertCopyFailed =>
'Копирането на обявата в клипборда не успя.';
@override
String get notification_activityTitle => 'Активност на MeshCore';
@override
String notification_messagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'съобщения',
one: 'съобщение',
);
return '$count $_temp0';
}
@override
String notification_channelMessagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'съобщения в канали',
one: 'съобщение в канал',
);
return '$count $_temp0';
}
@override
String notification_newNodesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'нови възли',
one: 'нов възел',
);
return '$count $_temp0';
}
@override
String notification_newTypeDiscovered(String contactType) {
return 'Открит нов $contactType';
}
@override
String get notification_receivedNewMessage => 'Получено ново съобщение';
@override
String get settings_gpxExportRepeaters =>
'Експортиране на повтарящи се устройства / сървър на стаята до GPX';

View file

@ -2778,6 +2778,50 @@ class AppLocalizationsDe extends AppLocalizations {
String get contacts_contactAdvertCopyFailed =>
'Kopieren des Werbeinhalts in die Zwischenablage fehlgeschlagen.';
@override
String get notification_activityTitle => 'MeshCore Aktivität';
@override
String notification_messagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Nachrichten',
one: 'Nachricht',
);
return '$count $_temp0';
}
@override
String notification_channelMessagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Kanalnachrichten',
one: 'Kanalnachricht',
);
return '$count $_temp0';
}
@override
String notification_newNodesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'neue Knoten',
one: 'neuer Knoten',
);
return '$count $_temp0';
}
@override
String notification_newTypeDiscovered(String contactType) {
return 'Neuer $contactType entdeckt';
}
@override
String get notification_receivedNewMessage => 'Neue Nachricht empfangen';
@override
String get settings_gpxExportRepeaters =>
'Repeater und Raumserver nach GPX exportieren';

View file

@ -2728,6 +2728,50 @@ class AppLocalizationsEn extends AppLocalizations {
String get contacts_contactAdvertCopyFailed =>
'Copying advert to Clipboard failed.';
@override
String get notification_activityTitle => 'MeshCore Activity';
@override
String notification_messagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'messages',
one: 'message',
);
return '$count $_temp0';
}
@override
String notification_channelMessagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'channel messages',
one: 'channel message',
);
return '$count $_temp0';
}
@override
String notification_newNodesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'new nodes',
one: 'new node',
);
return '$count $_temp0';
}
@override
String notification_newTypeDiscovered(String contactType) {
return 'New $contactType discovered';
}
@override
String get notification_receivedNewMessage => 'Received new message';
@override
String get settings_gpxExportRepeaters =>
'Export repeaters / room server to GPX';

View file

@ -2771,6 +2771,50 @@ class AppLocalizationsEs extends AppLocalizations {
String get contacts_contactAdvertCopyFailed =>
'Copiar anuncio al Portapapeles ha fallado.';
@override
String get notification_activityTitle => 'Actividad de MeshCore';
@override
String notification_messagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'mensajes',
one: 'mensaje',
);
return '$count $_temp0';
}
@override
String notification_channelMessagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'mensajes de canal',
one: 'mensaje de canal',
);
return '$count $_temp0';
}
@override
String notification_newNodesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'nuevos nodos',
one: 'nuevo nodo',
);
return '$count $_temp0';
}
@override
String notification_newTypeDiscovered(String contactType) {
return 'Nuevo $contactType descubierto';
}
@override
String get notification_receivedNewMessage => 'Nuevo mensaje recibido';
@override
String get settings_gpxExportRepeaters =>
'Exportar repetidores / servidor de sala a GPX';

View file

@ -2792,6 +2792,50 @@ class AppLocalizationsFr extends AppLocalizations {
String get contacts_contactAdvertCopyFailed =>
'La copie de l\'annonce vers le presse-papiers a échoué.';
@override
String get notification_activityTitle => 'Activité MeshCore';
@override
String notification_messagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'messages',
one: 'message',
);
return '$count $_temp0';
}
@override
String notification_channelMessagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'messages de canal',
one: 'message de canal',
);
return '$count $_temp0';
}
@override
String notification_newNodesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'nouveaux nœuds',
one: 'nouveau nœud',
);
return '$count $_temp0';
}
@override
String notification_newTypeDiscovered(String contactType) {
return 'Nouveau $contactType découvert';
}
@override
String get notification_receivedNewMessage => 'Nouveau message reçu';
@override
String get settings_gpxExportRepeaters =>
'Exporter les répéteurs / serveur de salle au format GPX';

View file

@ -2774,6 +2774,50 @@ class AppLocalizationsIt extends AppLocalizations {
String get contacts_contactAdvertCopyFailed =>
'Copia dell\'annuncio nella Clipboard non riuscita.';
@override
String get notification_activityTitle => 'Attività MeshCore';
@override
String notification_messagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'messaggi',
one: 'messaggio',
);
return '$count $_temp0';
}
@override
String notification_channelMessagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'messaggi del canale',
one: 'messaggio del canale',
);
return '$count $_temp0';
}
@override
String notification_newNodesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'nuovi nodi',
one: 'nuovo nodo',
);
return '$count $_temp0';
}
@override
String notification_newTypeDiscovered(String contactType) {
return 'Nuovo $contactType scoperto';
}
@override
String get notification_receivedNewMessage => 'Nuovo messaggio ricevuto';
@override
String get settings_gpxExportRepeaters =>
'Esporta ripetitori / server di stanza in GPX';

View file

@ -2763,6 +2763,50 @@ class AppLocalizationsNl extends AppLocalizations {
String get contacts_contactAdvertCopyFailed =>
'Kopiëren van advertentie naar Clipboard is mislukt.';
@override
String get notification_activityTitle => 'MeshCore Activiteit';
@override
String notification_messagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'berichten',
one: 'bericht',
);
return '$count $_temp0';
}
@override
String notification_channelMessagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'kanaalberichten',
one: 'kanaalbericht',
);
return '$count $_temp0';
}
@override
String notification_newNodesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'nieuwe knooppunten',
one: 'nieuw knooppunt',
);
return '$count $_temp0';
}
@override
String notification_newTypeDiscovered(String contactType) {
return 'Nieuw $contactType ontdekt';
}
@override
String get notification_receivedNewMessage => 'Nieuw bericht ontvangen';
@override
String get settings_gpxExportRepeaters =>
'Exporteer repeaters / roomserver naar GPX';

View file

@ -2771,6 +2771,56 @@ class AppLocalizationsPl extends AppLocalizations {
String get contacts_contactAdvertCopyFailed =>
'Kopiowanie ogłoszenia do schowka nie powiodło się.';
@override
String get notification_activityTitle => 'Aktywność MeshCore';
@override
String notification_messagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'wiadomości',
many: 'wiadomości',
few: 'wiadomości',
one: 'wiadomość',
);
return '$count $_temp0';
}
@override
String notification_channelMessagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'wiadomości kanału',
many: 'wiadomości kanału',
few: 'wiadomości kanału',
one: 'wiadomość kanału',
);
return '$count $_temp0';
}
@override
String notification_newNodesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'nowych węzłów',
many: 'nowych węzłów',
few: 'nowe węzły',
one: 'nowy węzeł',
);
return '$count $_temp0';
}
@override
String notification_newTypeDiscovered(String contactType) {
return 'Nowy $contactType wykryty';
}
@override
String get notification_receivedNewMessage => 'Otrzymano nową wiadomość';
@override
String get settings_gpxExportRepeaters =>
'Eksportuj powtórki / serwer pokojowy do GPX';

View file

@ -2773,6 +2773,50 @@ class AppLocalizationsPt extends AppLocalizations {
String get contacts_contactAdvertCopyFailed =>
'Cópia do anúncio para a Área de Transferência falhou.';
@override
String get notification_activityTitle => 'Atividade MeshCore';
@override
String notification_messagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'mensagens',
one: 'mensagem',
);
return '$count $_temp0';
}
@override
String notification_channelMessagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'mensagens de canal',
one: 'mensagem de canal',
);
return '$count $_temp0';
}
@override
String notification_newNodesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'novos nós',
one: 'novo nó',
);
return '$count $_temp0';
}
@override
String notification_newTypeDiscovered(String contactType) {
return 'Novo $contactType descoberto';
}
@override
String get notification_receivedNewMessage => 'Nova mensagem recebida';
@override
String get settings_gpxExportRepeaters =>
'Exportar repetidores / servidor de sala para GPX';

View file

@ -2778,6 +2778,56 @@ class AppLocalizationsRu extends AppLocalizations {
String get contacts_contactAdvertCopyFailed =>
'Копирование рекламы в буфер обмена не удалось.';
@override
String get notification_activityTitle => 'Активность MeshCore';
@override
String notification_messagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'сообщений',
many: 'сообщений',
few: 'сообщения',
one: 'сообщение',
);
return '$count $_temp0';
}
@override
String notification_channelMessagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'сообщений канала',
many: 'сообщений канала',
few: 'сообщения канала',
one: 'сообщение канала',
);
return '$count $_temp0';
}
@override
String notification_newNodesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'новых узлов',
many: 'новых узлов',
few: 'новых узла',
one: 'новый узел',
);
return '$count $_temp0';
}
@override
String notification_newTypeDiscovered(String contactType) {
return 'Обнаружен новый $contactType';
}
@override
String get notification_receivedNewMessage => 'Получено новое сообщение';
@override
String get settings_gpxExportRepeaters =>
'Экспортировать рипитеры / сервер комнаты в GPX';

View file

@ -2757,6 +2757,53 @@ class AppLocalizationsSk extends AppLocalizations {
String get contacts_contactAdvertCopyFailed =>
'Kopírovanie inzerátu do schránky zlyhalo.';
@override
String get notification_activityTitle => 'Aktivita MeshCore';
@override
String notification_messagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'správ',
few: 'správy',
one: 'správa',
);
return '$count $_temp0';
}
@override
String notification_channelMessagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'správ kanálu',
few: 'správy kanálu',
one: 'správa kanálu',
);
return '$count $_temp0';
}
@override
String notification_newNodesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'nových uzlov',
few: 'nové uzly',
one: 'nový uzol',
);
return '$count $_temp0';
}
@override
String notification_newTypeDiscovered(String contactType) {
return 'Nový $contactType objavený';
}
@override
String get notification_receivedNewMessage => 'Prijatá nová správa';
@override
String get settings_gpxExportRepeaters =>
'Exportovať repeater / server miestnosti do GPX';

View file

@ -2759,6 +2759,56 @@ class AppLocalizationsSl extends AppLocalizations {
String get contacts_contactAdvertCopyFailed =>
'Kopiranje oglasa v odložišče je spodletelo.';
@override
String get notification_activityTitle => 'Aktivnost MeshCore';
@override
String notification_messagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'sporočil',
few: 'sporočila',
two: 'sporočili',
one: 'sporočilo',
);
return '$count $_temp0';
}
@override
String notification_channelMessagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'sporočil kanala',
few: 'sporočila kanala',
two: 'sporočili kanala',
one: 'sporočilo kanala',
);
return '$count $_temp0';
}
@override
String notification_newNodesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'novih vozlišč',
few: 'nova vozlišča',
two: 'novi vozlišči',
one: 'novo vozlišče',
);
return '$count $_temp0';
}
@override
String notification_newTypeDiscovered(String contactType) {
return 'Odkrito novo $contactType';
}
@override
String get notification_receivedNewMessage => 'Prejeto novo sporočilo';
@override
String get settings_gpxExportRepeaters =>
'Izvoz ponoviteljev / strežnika sobe v GPX';

View file

@ -2744,6 +2744,50 @@ class AppLocalizationsSv extends AppLocalizations {
String get contacts_contactAdvertCopyFailed =>
'Kopiering av annons till Urklipp misslyckades.';
@override
String get notification_activityTitle => 'MeshCore Aktivitet';
@override
String notification_messagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'meddelanden',
one: 'meddelande',
);
return '$count $_temp0';
}
@override
String notification_channelMessagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'kanalmeddelanden',
one: 'kanalmeddelande',
);
return '$count $_temp0';
}
@override
String notification_newNodesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'nya noder',
one: 'ny nod',
);
return '$count $_temp0';
}
@override
String notification_newTypeDiscovered(String contactType) {
return 'Ny $contactType upptäckt';
}
@override
String get notification_receivedNewMessage => 'Nytt meddelande mottaget';
@override
String get settings_gpxExportRepeaters =>
'Exportera repeater / rumsservrar till GPX';

View file

@ -2784,6 +2784,56 @@ class AppLocalizationsUk extends AppLocalizations {
String get contacts_contactAdvertCopyFailed =>
'Копіювання оголошення в буфер обміну завершилося невдало';
@override
String get notification_activityTitle => 'Активність MeshCore';
@override
String notification_messagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'повідомлень',
many: 'повідомлень',
few: 'повідомлення',
one: 'повідомлення',
);
return '$count $_temp0';
}
@override
String notification_channelMessagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'повідомлень каналу',
many: 'повідомлень каналу',
few: 'повідомлення каналу',
one: 'повідомлення каналу',
);
return '$count $_temp0';
}
@override
String notification_newNodesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'нових вузлів',
many: 'нових вузлів',
few: 'нових вузли',
one: 'новий вузол',
);
return '$count $_temp0';
}
@override
String notification_newTypeDiscovered(String contactType) {
return 'Виявлено новий $contactType';
}
@override
String get notification_receivedNewMessage => 'Отримано нове повідомлення';
@override
String get settings_gpxExportRepeaters =>
'Експортувати ретранслятори / сервер кімнати до GPX';

View file

@ -2625,6 +2625,32 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get contacts_contactAdvertCopyFailed => '将广告复制到剪贴板操作失败。';
@override
String get notification_activityTitle => 'MeshCore 活动';
@override
String notification_messagesCount(int count) {
return '$count 条消息';
}
@override
String notification_channelMessagesCount(int count) {
return '$count 条频道消息';
}
@override
String notification_newNodesCount(int count) {
return '$count 个新节点';
}
@override
String notification_newTypeDiscovered(String contactType) {
return '发现新 $contactType';
}
@override
String get notification_receivedNewMessage => '收到新消息';
@override
String get settings_gpxExportRepeaters => '导出重复器/房间服务器到GPX';

View file

@ -1569,6 +1569,13 @@
"contacts_ShareContact": "Kontakt naar Klembord kopiëren",
"contacts_ShareContactZeroHop": "Contact delen via advertentie",
"contacts_zeroHopContactAdvertFailed": "Mislukt om contact te verzenden",
"notification_activityTitle": "MeshCore Activiteit",
"notification_messagesCount": "{count} {count, plural, =1{bericht} other{berichten}}",
"notification_channelMessagesCount": "{count} {count, plural, =1{kanaalbericht} other{kanaalberichten}}",
"notification_newNodesCount": "{count} {count, plural, =1{nieuw knooppunt} other{nieuwe knooppunten}}",
"notification_newTypeDiscovered": "Nieuw {contactType} ontdekt",
"notification_receivedNewMessage": "Nieuw bericht ontvangen",
"contacts_zeroHopContactAdvertFailed": "Mislukt om contact te verzenden",
"settings_gpxExportRepeatersSubtitle": "Exporteert repeaters / roomserver met een locatie naar GPX-bestand.",
"settings_gpxExportRepeaters": "Exporteer repeaters / roomserver naar GPX",
"settings_gpxExportSuccess": "Succesvol GPX-bestand geëxporteerd.",
@ -1585,4 +1592,5 @@
"settings_gpxExportShareText": "Kaartgegevens geëxporteerd uit meshcore-open",
"settings_gpxExportShareSubject": "meshcore-open GPX kaartgegevens exporteren",
"pathTrace_someHopsNoLocation": "Een of meer van de hops ontbreken een locatie!"
}

View file

@ -1569,6 +1569,13 @@
"contacts_ShareContactZeroHop": "Udostępnij kontakt przez ogłoszenie",
"contacts_ShareContact": "Kopiuj kontakt do schowka",
"contacts_zeroHopContactAdvertFailed": "Nie udało się wysłać kontaktu.",
"notification_activityTitle": "Aktywność MeshCore",
"notification_messagesCount": "{count} {count, plural, =1{wiadomość} few{wiadomości} many{wiadomości} other{wiadomości}}",
"notification_channelMessagesCount": "{count} {count, plural, =1{wiadomość kanału} few{wiadomości kanału} many{wiadomości kanału} other{wiadomości kanału}}",
"notification_newNodesCount": "{count} {count, plural, =1{nowy węzeł} few{nowe węzły} many{nowych węzłów} other{nowych węzłów}}",
"notification_newTypeDiscovered": "Nowy {contactType} wykryty",
"notification_receivedNewMessage": "Otrzymano nową wiadomość",
"contacts_zeroHopContactAdvertFailed": "Nie udało się wysłać kontaktu.",
"settings_gpxExportContacts": "Eksportuj towarzyszy do GPX",
"settings_gpxExportRepeaters": "Eksportuj powtórki / serwer pokojowy do GPX",
"settings_gpxExportRepeatersSubtitle": "Eksportuje powtarzacze / roomserver z lokalizacją do pliku GPX.",
@ -1585,4 +1592,5 @@
"settings_gpxExportShareText": "Dane mapy wyeksportowane z meshcore-open",
"settings_gpxExportShareSubject": "Eksport danych mapy GPX meshcore-open",
"pathTrace_someHopsNoLocation": "Jeden lub więcej z chmieli nie ma określonej lokalizacji!"
}

View file

@ -1569,6 +1569,13 @@
"contacts_contactAdvertCopyFailed": "Cópia do anúncio para a Área de Transferência falhou.",
"contacts_ShareContactZeroHop": "Compartilhar contato por anúncio",
"contacts_zeroHopContactAdvertFailed": "Falha ao enviar contato.",
"notification_activityTitle": "Atividade MeshCore",
"notification_messagesCount": "{count} {count, plural, =1{mensagem} other{mensagens}}",
"notification_channelMessagesCount": "{count} {count, plural, =1{mensagem de canal} other{mensagens de canal}}",
"notification_newNodesCount": "{count} {count, plural, =1{novo nó} other{novos nós}}",
"notification_newTypeDiscovered": "Novo {contactType} descoberto",
"notification_receivedNewMessage": "Nova mensagem recebida",
"contacts_zeroHopContactAdvertFailed": "Falha ao enviar contato.",
"settings_gpxExportRepeaters": "Exportar repetidores / servidor de sala para GPX",
"settings_gpxExportRepeatersSubtitle": "Exporta repetidores / roomserver com localização para arquivo GPX.",
"settings_gpxExportSuccess": "Arquivo GPX exportado com sucesso.",
@ -1585,4 +1592,5 @@
"settings_gpxExportShareText": "Dados do mapa exportados do meshcore-open",
"settings_gpxExportShareSubject": "meshcore-open exportação de dados de mapa GPX",
"pathTrace_someHopsNoLocation": "Um ou mais dos lúpulos estão sem localização!"
}

View file

@ -809,6 +809,13 @@
"contacts_addContactFromClipboard": "Добавить контакт из буфера обмена",
"contacts_ShareContactZeroHop": "Поделиться контактом по объявлению",
"contacts_zeroHopContactAdvertSent": "Отправлено сообщение по объявлению.",
"notification_activityTitle": "Активность MeshCore",
"notification_messagesCount": "{count} {count, plural, =1{сообщение} few{сообщения} many{сообщений} other{сообщений}}",
"notification_channelMessagesCount": "{count} {count, plural, =1{сообщение канала} few{сообщения канала} many{сообщений канала} other{сообщений канала}}",
"notification_newNodesCount": "{count} {count, plural, =1{новый узел} few{новых узла} many{новых узлов} other{новых узлов}}",
"notification_newTypeDiscovered": "Обнаружен новый {contactType}",
"notification_receivedNewMessage": "Получено новое сообщение",
"contacts_zeroHopContactAdvertSent": "Отправлено сообщение по объявлению.",
"settings_gpxExportRepeaters": "Экспортировать рипитеры / сервер комнаты в GPX",
"settings_gpxExportRepeatersSubtitle": "Экспортирует ретрансляторы / сервер комнат с местоположением в файл GPX.",
"settings_gpxExportContacts": "Экспортировать спутников в GPX",
@ -825,4 +832,5 @@
"settings_gpxExportShareText": "Данные карты экспортированы из meshcore-open",
"settings_gpxExportShareSubject": "meshcore-open экспорт данных карты GPX",
"pathTrace_someHopsNoLocation": "Одному или нескольким хмелям не указано местоположение!"
}

View file

@ -1569,6 +1569,13 @@
"contacts_zeroHopContactAdvertFailed": "Zlyhalo odoslanie kontaktu.",
"contacts_ShareContactZeroHop": "Zdieľať kontakt cez inzerát",
"contacts_ShareContact": "Kopírovať kontakt do schránky",
"notification_activityTitle": "Aktivita MeshCore",
"notification_messagesCount": "{count} {count, plural, =1{správa} few{správy} other{správ}}",
"notification_channelMessagesCount": "{count} {count, plural, =1{správa kanálu} few{správy kanálu} other{správ kanálu}}",
"notification_newNodesCount": "{count} {count, plural, =1{nový uzol} few{nové uzly} other{nových uzlov}}",
"notification_newTypeDiscovered": "Nový {contactType} objavený",
"notification_receivedNewMessage": "Prijatá nová správa",
"contacts_ShareContact": "Kopírovať kontakt do schránky",
"settings_gpxExportRepeatersSubtitle": "Exportuje repeater / roomserver s lokalitou do súboru GPX.",
"settings_gpxExportContacts": "Export sprievodcov do GPX",
"settings_gpxExportSuccess": "Úspešne exportovaný súbor GPX.",
@ -1585,4 +1592,5 @@
"settings_gpxExportShareText": "Mapové údaje exportované z meshcore-open",
"settings_gpxExportShareSubject": "meshcore-open export dát GPX mapových údajov",
"pathTrace_someHopsNoLocation": "Jedna alebo viac chmeľov chýba lokalita!"
}

View file

@ -1569,6 +1569,13 @@
"contacts_contactAdvertCopyFailed": "Kopiranje oglasa v odložišče je spodletelo.",
"contacts_ShareContactZeroHop": "Deliti kontakt prek oglasa",
"contacts_ShareContact": "Kopiraj stik v Odložišče",
"notification_activityTitle": "Aktivnost MeshCore",
"notification_messagesCount": "{count} {count, plural, =1{sporočilo} =2{sporočili} few{sporočila} other{sporočil}}",
"notification_channelMessagesCount": "{count} {count, plural, =1{sporočilo kanala} =2{sporočili kanala} few{sporočila kanala} other{sporočil kanala}}",
"notification_newNodesCount": "{count} {count, plural, =1{novo vozlišče} =2{novi vozlišči} few{nova vozlišča} other{novih vozlišč}}",
"notification_newTypeDiscovered": "Odkrito novo {contactType}",
"notification_receivedNewMessage": "Prejeto novo sporočilo",
"contacts_ShareContact": "Kopiraj stik v Odložišče",
"settings_gpxExportAll": "Izvozi vse kontakte v GPX",
"settings_gpxExportContacts": "Izvoz spremljevalcev v GPX",
"settings_gpxExportRepeatersSubtitle": "Izvozi ponovljene oddajnike / strežnik sobe z lokacijo v datoteko GPX.",
@ -1585,4 +1592,5 @@
"settings_gpxExportNotAvailable": "Ni podprto na vašem napravi/operacijskem sistemu",
"settings_gpxExportShareSubject": "meshcore-open izvoz podatkov GPX karte",
"pathTrace_someHopsNoLocation": "Ena ali več hmelju manjka lokacija!"
}

View file

@ -1569,6 +1569,13 @@
"contacts_ShareContact": "Kopiera kontakt till Urklipp",
"contacts_zeroHopContactAdvertFailed": "Misslyckades med att skicka kontakt.",
"contacts_ShareContactZeroHop": "Dela kontakt via annons",
"notification_activityTitle": "MeshCore Aktivitet",
"notification_messagesCount": "{count} {count, plural, =1{meddelande} other{meddelanden}}",
"notification_channelMessagesCount": "{count} {count, plural, =1{kanalmeddelande} other{kanalmeddelanden}}",
"notification_newNodesCount": "{count} {count, plural, =1{ny nod} other{nya noder}}",
"notification_newTypeDiscovered": "Ny {contactType} upptäckt",
"notification_receivedNewMessage": "Nytt meddelande mottaget",
"contacts_ShareContactZeroHop": "Dela kontakt via annons",
"settings_gpxExportAll": "Exportera alla kontakter till GPX",
"settings_gpxExportRepeatersSubtitle": "Exporterar repeater / roomserver med plats till GPX-fil.",
"settings_gpxExportSuccess": "Har exporterat GPX-fil med framgång",
@ -1585,4 +1592,5 @@
"settings_gpxExportShareSubject": "meshcore-open export av GPX-kartdata",
"settings_gpxExportShareText": "Kartdata exporterad från meshcore-open",
"pathTrace_someHopsNoLocation": "En eller flera av humlen saknar en plats!"
}

View file

@ -1569,6 +1569,13 @@
"contacts_zeroHopContactAdvertSent": "Відправлено контакт за оголошенням",
"contacts_addContactFromClipboard": "Додати контакт з буфера обміну",
"contacts_ShareContactZeroHop": "Поділитися контактом за оголошенням",
"notification_activityTitle": "Активність MeshCore",
"notification_messagesCount": "{count} {count, plural, =1{повідомлення} few{повідомлення} many{повідомлень} other{повідомлень}}",
"notification_channelMessagesCount": "{count} {count, plural, =1{повідомлення каналу} few{повідомлення каналу} many{повідомлень каналу} other{повідомлень каналу}}",
"notification_newNodesCount": "{count} {count, plural, =1{новий вузол} few{нових вузли} many{нових вузлів} other{нових вузлів}}",
"notification_newTypeDiscovered": "Виявлено новий {contactType}",
"notification_receivedNewMessage": "Отримано нове повідомлення",
"contacts_ShareContactZeroHop": "Поділитися контактом за оголошенням",
"settings_gpxExportRepeaters": "Експортувати ретранслятори / сервер кімнати до GPX",
"settings_gpxExportRepeatersSubtitle": "Експортує ретранслятори / сервер кімнати з місцезнаходженням у файл GPX.",
"settings_gpxExportSuccess": "Успішно експортовано файл GPX.",
@ -1585,4 +1592,5 @@
"settings_gpxExportAllContacts": "Усі місця контактів",
"settings_gpxExportShareSubject": "експорт даних карти meshcore-open у форматі GPX",
"pathTrace_someHopsNoLocation": "Одне або більше хмелів відсутнє місце розташування!"
}

View file

@ -1569,6 +1569,13 @@
"contacts_zeroHopContactAdvertFailed": "发送联系方式失败。",
"contacts_contactAdvertCopied": "广告内容已复制到剪贴板。",
"contacts_contactAdvertCopyFailed": "将广告复制到剪贴板操作失败。",
"notification_activityTitle": "MeshCore 活动",
"notification_messagesCount": "{count} 条消息",
"notification_channelMessagesCount": "{count} 条频道消息",
"notification_newNodesCount": "{count} 个新节点",
"notification_newTypeDiscovered": "发现新 {contactType}",
"notification_receivedNewMessage": "收到新消息",
"contacts_contactAdvertCopyFailed": "将广告复制到剪贴板操作失败。",
"settings_gpxExportRepeaters": "导出重复器/房间服务器到GPX",
"settings_gpxExportRepeatersSubtitle": "导出带有位置的重复器/房间服务器到GPX文件。",
"settings_gpxExportContactsSubtitle": "导出带有位置的伙伴到GPX文件。",
@ -1585,4 +1592,5 @@
"settings_gpxExportShareText": "来自meshcore-open的导出地图数据",
"settings_gpxExportShareSubject": "meshcore-open GPX 地图数据导出",
"pathTrace_someHopsNoLocation": "其中一个或多个啤酒花缺少位置!"
}

View file

@ -150,6 +150,12 @@ class MeshCoreApp extends StatelessWidget {
themeMode: _themeModeFromSetting(
settingsService.settings.themeMode,
),
builder: (context, child) {
// Update notification service with resolved locale
final locale = Localizations.localeOf(context);
NotificationService().setLocale(locale);
return child ?? const SizedBox.shrink();
},
home: const ScannerScreen(),
);
},

View file

@ -1,6 +1,10 @@
import 'dart:ui';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter/foundation.dart';
import '../l10n/app_localizations.dart';
class NotificationService {
static final NotificationService _instance = NotificationService._internal();
factory NotificationService() => _instance;
@ -10,6 +14,34 @@ class NotificationService {
FlutterLocalNotificationsPlugin();
bool _isInitialized = false;
// Locale for localized notification strings
Locale _locale = const Locale('en');
/// Set the locale for notification strings (call when app locale changes)
void setLocale(Locale locale) {
_locale = locale;
}
AppLocalizations get _l10n => lookupAppLocalizations(_locale);
// Rate limiting to prevent notification storms
// (Added after getting notification-flooded while evaluating RF flood management. The irony.)
static const _minNotificationInterval = Duration(seconds: 3);
static const _batchWindow = Duration(seconds: 5);
DateTime? _lastNotificationTime;
final List<_PendingNotification> _pendingNotifications = [];
bool _isBatchingActive = false;
bool _suppressNotifications = false;
/// Temporarily suppress all notifications (e.g., during sync)
void suppressNotifications(bool suppress) {
_suppressNotifications = suppress;
if (suppress) {
_pendingNotifications.clear();
}
}
Future<void> initialize() async {
if (_isInitialized) return;
@ -76,7 +108,7 @@ class NotificationService {
return true;
}
Future<void> showMessageNotification({
Future<void> _showMessageNotificationImpl({
required String contactName,
required String message,
String? contactId,
@ -125,7 +157,7 @@ class NotificationService {
);
}
Future<void> showAdvertNotification({
Future<void> _showAdvertNotificationImpl({
required String contactName,
required String contactType,
String? contactId,
@ -163,14 +195,14 @@ class NotificationService {
await _notifications.show(
contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
'New $contactType discovered',
_l10n.notification_newTypeDiscovered(contactType),
contactName,
notificationDetails,
payload: 'advert:$contactId',
);
}
Future<void> showChannelMessageNotification({
Future<void> _showChannelMessageNotificationImpl({
required String channelName,
required String message,
int? channelIndex,
@ -211,7 +243,9 @@ class NotificationService {
);
final preview = message.trim();
final body = preview.isEmpty ? 'Received new message' : preview;
final body = preview.isEmpty
? _l10n.notification_receivedNewMessage
: preview;
await _notifications.show(
channelIndex?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
@ -222,6 +256,21 @@ class NotificationService {
);
}
/// Returns a privacy-safe identifier for debug logging.
/// - advert: shows device name (body contains contactName)
/// - message: shows "from: sender" (avoids logging message content)
/// - channelMessage: shows "in: channel" (avoids logging message content)
String _getNotificationIdentifier(_PendingNotification n) {
switch (n.type) {
case _NotificationType.advert:
return n.body;
case _NotificationType.message:
return 'from: ${n.title}';
case _NotificationType.channelMessage:
return 'in: ${n.title}';
}
}
void _onNotificationTapped(NotificationResponse response) {
final payload = response.payload;
if (payload != null) {
@ -238,4 +287,212 @@ class NotificationService {
Future<void> cancel(int id) async {
await _notifications.cancel(id);
}
//
// Public notification methods (rate limiting is enforced automatically)
//
Future<void> showMessageNotification({
required String contactName,
required String message,
String? contactId,
int? badgeCount,
}) async {
if (_suppressNotifications) return;
_queueNotification(
_PendingNotification(
type: _NotificationType.message,
title: contactName,
body: message,
id: contactId,
badgeCount: badgeCount,
),
);
}
Future<void> showAdvertNotification({
required String contactName,
required String contactType,
String? contactId,
}) async {
if (_suppressNotifications) return;
_queueNotification(
_PendingNotification(
type: _NotificationType.advert,
title: contactType,
body: contactName,
id: contactId,
),
);
}
Future<void> showChannelMessageNotification({
required String channelName,
required String message,
int? channelIndex,
int? badgeCount,
}) async {
if (_suppressNotifications) return;
_queueNotification(
_PendingNotification(
type: _NotificationType.channelMessage,
title: channelName,
body: message,
id: channelIndex?.toString(),
badgeCount: badgeCount,
),
);
}
void _queueNotification(_PendingNotification notification) {
final now = DateTime.now();
// If we recently showed a notification, start batching
if (_lastNotificationTime != null &&
now.difference(_lastNotificationTime!) < _minNotificationInterval) {
_pendingNotifications.add(notification);
debugPrint(
'[Notification] queued: ${notification.type.name} (${_getNotificationIdentifier(notification)})',
);
// Start batch timer if not already running
if (!_isBatchingActive) {
_isBatchingActive = true;
Future.delayed(_batchWindow, _processBatch);
}
return;
}
// Show immediately if enough time has passed
debugPrint(
'[Notification] sent immediately: ${notification.type.name} (${_getNotificationIdentifier(notification)})',
);
_showNotificationImmediately(notification);
_lastNotificationTime = now;
}
Future<void> _processBatch() async {
_isBatchingActive = false;
if (_pendingNotifications.isEmpty) return;
final batch = List<_PendingNotification>.from(_pendingNotifications);
_pendingNotifications.clear();
if (batch.length == 1) {
// Single notification, show normally
_showNotificationImmediately(batch.first);
} else {
// Multiple notifications, show summary
await _showBatchSummary(batch);
}
_lastNotificationTime = DateTime.now();
}
Future<void> _showNotificationImmediately(
_PendingNotification notification,
) async {
switch (notification.type) {
case _NotificationType.message:
await _showMessageNotificationImpl(
contactName: notification.title,
message: notification.body,
contactId: notification.id,
badgeCount: notification.badgeCount,
);
break;
case _NotificationType.advert:
await _showAdvertNotificationImpl(
contactName: notification.body,
contactType: notification.title,
contactId: notification.id,
);
break;
case _NotificationType.channelMessage:
await _showChannelMessageNotificationImpl(
channelName: notification.title,
message: notification.body,
channelIndex: int.tryParse(notification.id ?? ''),
badgeCount: notification.badgeCount,
);
break;
}
}
Future<void> _showBatchSummary(List<_PendingNotification> batch) async {
if (!_isInitialized) await initialize();
// Group by type
final messages = batch
.where((n) => n.type == _NotificationType.message)
.toList();
final adverts = batch
.where((n) => n.type == _NotificationType.advert)
.toList();
final channelMsgs = batch
.where((n) => n.type == _NotificationType.channelMessage)
.toList();
// Build summary text using localized plurals
final parts = <String>[];
if (messages.isNotEmpty) {
parts.add(_l10n.notification_messagesCount(messages.length));
}
if (channelMsgs.isNotEmpty) {
parts.add(_l10n.notification_channelMessagesCount(channelMsgs.length));
}
if (adverts.isNotEmpty) {
parts.add(_l10n.notification_newNodesCount(adverts.length));
}
if (parts.isEmpty) return;
// Show first few device names in batch summary for debugging (only if adverts exist)
final deviceInfo = adverts.isNotEmpty
? ' (${adverts.take(5).map((n) => n.body).join(', ')}${adverts.length > 5 ? ', ...' : ''})'
: '';
debugPrint('[Notification] batch summary: ${parts.join(", ")}$deviceInfo');
const androidDetails = AndroidNotificationDetails(
'batch_summary',
'Activity Summary',
channelDescription: 'Batched notification summaries',
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
icon: '@mipmap/ic_launcher',
);
const notificationDetails = NotificationDetails(android: androidDetails);
await _notifications.show(
'batch_summary'.hashCode,
_l10n.notification_activityTitle,
parts.join(', '),
notificationDetails,
payload: 'batch',
);
}
}
// Helper class for pending notifications
enum _NotificationType { message, advert, channelMessage }
class _PendingNotification {
final _NotificationType type;
final String title;
final String body;
final String? id;
final int? badgeCount;
_PendingNotification({
required this.type,
required this.title,
required this.body,
this.id,
this.badgeCount,
});
}

View file

@ -1 +1,127 @@
{}
{
"bg": [
"notification_activityTitle",
"notification_messagesCount",
"notification_channelMessagesCount",
"notification_newNodesCount",
"notification_newTypeDiscovered",
"notification_receivedNewMessage"
],
"de": [
"notification_activityTitle",
"notification_messagesCount",
"notification_channelMessagesCount",
"notification_newNodesCount",
"notification_newTypeDiscovered",
"notification_receivedNewMessage"
],
"es": [
"notification_activityTitle",
"notification_messagesCount",
"notification_channelMessagesCount",
"notification_newNodesCount",
"notification_newTypeDiscovered",
"notification_receivedNewMessage"
],
"fr": [
"notification_activityTitle",
"notification_messagesCount",
"notification_channelMessagesCount",
"notification_newNodesCount",
"notification_newTypeDiscovered",
"notification_receivedNewMessage"
],
"it": [
"notification_activityTitle",
"notification_messagesCount",
"notification_channelMessagesCount",
"notification_newNodesCount",
"notification_newTypeDiscovered",
"notification_receivedNewMessage"
],
"nl": [
"notification_activityTitle",
"notification_messagesCount",
"notification_channelMessagesCount",
"notification_newNodesCount",
"notification_newTypeDiscovered",
"notification_receivedNewMessage"
],
"pl": [
"notification_activityTitle",
"notification_messagesCount",
"notification_channelMessagesCount",
"notification_newNodesCount",
"notification_newTypeDiscovered",
"notification_receivedNewMessage"
],
"pt": [
"notification_activityTitle",
"notification_messagesCount",
"notification_channelMessagesCount",
"notification_newNodesCount",
"notification_newTypeDiscovered",
"notification_receivedNewMessage"
],
"ru": [
"notification_activityTitle",
"notification_messagesCount",
"notification_channelMessagesCount",
"notification_newNodesCount",
"notification_newTypeDiscovered",
"notification_receivedNewMessage"
],
"sk": [
"notification_activityTitle",
"notification_messagesCount",
"notification_channelMessagesCount",
"notification_newNodesCount",
"notification_newTypeDiscovered",
"notification_receivedNewMessage"
],
"sl": [
"notification_activityTitle",
"notification_messagesCount",
"notification_channelMessagesCount",
"notification_newNodesCount",
"notification_newTypeDiscovered",
"notification_receivedNewMessage"
],
"sv": [
"notification_activityTitle",
"notification_messagesCount",
"notification_channelMessagesCount",
"notification_newNodesCount",
"notification_newTypeDiscovered",
"notification_receivedNewMessage"
],
"uk": [
"notification_activityTitle",
"notification_messagesCount",
"notification_channelMessagesCount",
"notification_newNodesCount",
"notification_newTypeDiscovered",
"notification_receivedNewMessage"
],
"zh": [
"notification_activityTitle",
"notification_messagesCount",
"notification_channelMessagesCount",
"notification_newNodesCount",
"notification_newTypeDiscovered",
"notification_receivedNewMessage"
]
}