mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
* 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>
498 lines
14 KiB
Dart
498 lines
14 KiB
Dart
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;
|
|
NotificationService._internal();
|
|
|
|
final FlutterLocalNotificationsPlugin _notifications =
|
|
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;
|
|
|
|
const androidSettings = AndroidInitializationSettings(
|
|
'@mipmap/ic_launcher',
|
|
);
|
|
const iosSettings = DarwinInitializationSettings(
|
|
requestAlertPermission: true,
|
|
requestBadgePermission: true,
|
|
requestSoundPermission: true,
|
|
);
|
|
const macSettings = DarwinInitializationSettings(
|
|
requestAlertPermission: true,
|
|
requestBadgePermission: true,
|
|
requestSoundPermission: true,
|
|
);
|
|
|
|
const initSettings = InitializationSettings(
|
|
android: androidSettings,
|
|
iOS: iosSettings,
|
|
macOS: macSettings,
|
|
);
|
|
|
|
try {
|
|
await _notifications.initialize(
|
|
initSettings,
|
|
onDidReceiveNotificationResponse: _onNotificationTapped,
|
|
);
|
|
_isInitialized = true;
|
|
} catch (e) {
|
|
debugPrint('Error initializing notifications: $e');
|
|
}
|
|
}
|
|
|
|
Future<bool> requestPermissions() async {
|
|
if (!_isInitialized) {
|
|
await initialize();
|
|
}
|
|
|
|
// Request Android 13+ notification permission
|
|
final androidPlugin = _notifications
|
|
.resolvePlatformSpecificImplementation<
|
|
AndroidFlutterLocalNotificationsPlugin
|
|
>();
|
|
if (androidPlugin != null) {
|
|
final granted = await androidPlugin.requestNotificationsPermission();
|
|
return granted ?? false;
|
|
}
|
|
|
|
// iOS permissions are requested during initialization
|
|
final iosPlugin = _notifications
|
|
.resolvePlatformSpecificImplementation<
|
|
IOSFlutterLocalNotificationsPlugin
|
|
>();
|
|
if (iosPlugin != null) {
|
|
final granted = await iosPlugin.requestPermissions(
|
|
alert: true,
|
|
badge: true,
|
|
sound: true,
|
|
);
|
|
return granted ?? false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
Future<void> _showMessageNotificationImpl({
|
|
required String contactName,
|
|
required String message,
|
|
String? contactId,
|
|
int? badgeCount,
|
|
}) async {
|
|
if (!_isInitialized) {
|
|
await initialize();
|
|
}
|
|
|
|
final androidDetails = AndroidNotificationDetails(
|
|
'messages',
|
|
'Messages',
|
|
channelDescription: 'New message notifications',
|
|
importance: Importance.high,
|
|
priority: Priority.high,
|
|
icon: '@mipmap/ic_launcher',
|
|
number: badgeCount,
|
|
);
|
|
|
|
final iosDetails = DarwinNotificationDetails(
|
|
presentAlert: true,
|
|
presentBadge: true,
|
|
presentSound: true,
|
|
badgeNumber: badgeCount,
|
|
);
|
|
|
|
final macDetails = DarwinNotificationDetails(
|
|
presentAlert: true,
|
|
presentBadge: true,
|
|
presentSound: true,
|
|
badgeNumber: badgeCount,
|
|
);
|
|
|
|
final notificationDetails = NotificationDetails(
|
|
android: androidDetails,
|
|
iOS: iosDetails,
|
|
macOS: macDetails,
|
|
);
|
|
|
|
await _notifications.show(
|
|
contactId?.hashCode ?? 0,
|
|
contactName,
|
|
message,
|
|
notificationDetails,
|
|
payload: 'message:$contactId',
|
|
);
|
|
}
|
|
|
|
Future<void> _showAdvertNotificationImpl({
|
|
required String contactName,
|
|
required String contactType,
|
|
String? contactId,
|
|
}) async {
|
|
if (!_isInitialized) {
|
|
await initialize();
|
|
}
|
|
|
|
const androidDetails = AndroidNotificationDetails(
|
|
'adverts',
|
|
'Advertisements',
|
|
channelDescription: 'New node advertisement notifications',
|
|
importance: Importance.defaultImportance,
|
|
priority: Priority.defaultPriority,
|
|
icon: '@mipmap/ic_launcher',
|
|
);
|
|
|
|
const iosDetails = DarwinNotificationDetails(
|
|
presentAlert: true,
|
|
presentBadge: true,
|
|
presentSound: true,
|
|
);
|
|
|
|
const macDetails = DarwinNotificationDetails(
|
|
presentAlert: true,
|
|
presentBadge: true,
|
|
presentSound: true,
|
|
);
|
|
|
|
const notificationDetails = NotificationDetails(
|
|
android: androidDetails,
|
|
iOS: iosDetails,
|
|
macOS: macDetails,
|
|
);
|
|
|
|
await _notifications.show(
|
|
contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
|
|
_l10n.notification_newTypeDiscovered(contactType),
|
|
contactName,
|
|
notificationDetails,
|
|
payload: 'advert:$contactId',
|
|
);
|
|
}
|
|
|
|
Future<void> _showChannelMessageNotificationImpl({
|
|
required String channelName,
|
|
required String message,
|
|
int? channelIndex,
|
|
int? badgeCount,
|
|
}) async {
|
|
if (!_isInitialized) {
|
|
await initialize();
|
|
}
|
|
|
|
final androidDetails = AndroidNotificationDetails(
|
|
'channel_messages',
|
|
'Channel Messages',
|
|
channelDescription: 'New channel message notifications',
|
|
importance: Importance.high,
|
|
priority: Priority.high,
|
|
icon: '@mipmap/ic_launcher',
|
|
number: badgeCount,
|
|
);
|
|
|
|
final iosDetails = DarwinNotificationDetails(
|
|
presentAlert: true,
|
|
presentBadge: true,
|
|
presentSound: true,
|
|
badgeNumber: badgeCount,
|
|
);
|
|
|
|
final macDetails = DarwinNotificationDetails(
|
|
presentAlert: true,
|
|
presentBadge: true,
|
|
presentSound: true,
|
|
badgeNumber: badgeCount,
|
|
);
|
|
|
|
final notificationDetails = NotificationDetails(
|
|
android: androidDetails,
|
|
iOS: iosDetails,
|
|
macOS: macDetails,
|
|
);
|
|
|
|
final preview = message.trim();
|
|
final body = preview.isEmpty
|
|
? _l10n.notification_receivedNewMessage
|
|
: preview;
|
|
|
|
await _notifications.show(
|
|
channelIndex?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
|
|
channelName,
|
|
body,
|
|
notificationDetails,
|
|
payload: 'channel:$channelIndex',
|
|
);
|
|
}
|
|
|
|
/// 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) {
|
|
debugPrint('Notification tapped: $payload');
|
|
// Handle navigation based on payload
|
|
// This can be extended to navigate to specific screens
|
|
}
|
|
}
|
|
|
|
Future<void> cancelAll() async {
|
|
await _notifications.cancelAll();
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|