fix(notif): fix Android background push notification delivery and deduplicate alerts
This commit is contained in:
1 parent
0f8cf0a5f9
commit
d0164c0db3
8 files changed
+170
-38
No files matched your search
@@ -2,9 +2,28 @@ const db = require('../config/db');
|
||||
const fcmService = require('../services/fcmService');
|
||||
fcmService.init();
|
||||
|
||||
// In-memory debouncer to prevent rapid duplicate notification creation (within 2 seconds)
|
||||
const _recentCreates = new Map();
|
||||
|
||||
// Programmatic helper to create notifications (can be called from other controllers)
|
||||
// category: 'student' | 'document' | 'user' | 'system'
|
||||
exports.createNotification = (userId, title, message, category = 'system', entityType = null, entityId = null) => {
|
||||
// Deduplication check: suppress identical notifications fired within 2000ms
|
||||
const dedupeKey = `${userId || 'all'}_${title}_${message}_${entityType || ''}_${entityId || ''}`;
|
||||
const now = Date.now();
|
||||
const lastCreated = _recentCreates.get(dedupeKey);
|
||||
if (lastCreated && (now - lastCreated < 2000)) {
|
||||
return; // Suppress duplicate call
|
||||
}
|
||||
_recentCreates.set(dedupeKey, now);
|
||||
|
||||
// Evict old cache entries periodically
|
||||
if (_recentCreates.size > 200) {
|
||||
for (const [k, t] of _recentCreates.entries()) {
|
||||
if (now - t > 10000) _recentCreates.delete(k);
|
||||
}
|
||||
}
|
||||
|
||||
let notifId = null;
|
||||
try {
|
||||
const result = db.prepare('INSERT INTO notifications (user_id, title, message, is_read, category, entity_type, entity_id) VALUES (?, ?, ?, 0, ?, ?, ?)')
|
||||
@@ -44,6 +63,18 @@ exports.registerFcmToken = (req, res) => {
|
||||
SET user_id = excluded.user_id,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(req.user.id, token);
|
||||
|
||||
// Prune stale tokens: keep only the 3 most recently updated tokens for this user
|
||||
db.prepare(`
|
||||
DELETE FROM fcm_tokens
|
||||
WHERE user_id = ? AND id NOT IN (
|
||||
SELECT id FROM fcm_tokens
|
||||
WHERE user_id = ?
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
LIMIT 3
|
||||
)
|
||||
`).run(req.user.id, req.user.id);
|
||||
|
||||
res.json({ message: 'FCM token registered' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: 'Failed to register FCM token', error: error.message });
|
||||
|
||||
@@ -101,7 +101,10 @@ const _sendMulticast = async (tokens, title, body, category = 'system', notifica
|
||||
priority: 'high',
|
||||
defaultSound: true,
|
||||
defaultVibrateTimings: true,
|
||||
visibility: 'public'
|
||||
visibility: 'public',
|
||||
icon: 'ic_launcher_foreground',
|
||||
clickAction: 'FLUTTER_NOTIFICATION_CLICK',
|
||||
tag: notificationId ? String(notificationId) : undefined
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -188,9 +191,16 @@ const sendNotification = async ({ userId = null, title, body, category = 'system
|
||||
WHERE ts.section_id = ? AND u.role = 'teacher' AND u.is_active = 1
|
||||
`).all(entityId).map(r => r.token);
|
||||
tokens.push(...teacherTokens);
|
||||
} else if (!entityType) {
|
||||
// Broadcast to all active teachers for unscoped system notifications
|
||||
const allTeacherTokens = db.prepare(`
|
||||
SELECT ft.token
|
||||
FROM fcm_tokens ft
|
||||
JOIN users u ON ft.user_id = u.id
|
||||
WHERE u.role = 'teacher' AND u.is_active = 1
|
||||
`).all().map(r => r.token);
|
||||
tokens.push(...allTeacherTokens);
|
||||
}
|
||||
// Note: Unscoped notifications (entityType is null or not student/document/section)
|
||||
// are delivered solely to Admins and not broadcast to teachers.
|
||||
}
|
||||
|
||||
await _sendMulticast(tokens, title, body, category, notificationId);
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
<meta-data
|
||||
android:name="com.pravera.flutter_foreground_task.notification_icon"
|
||||
android:resource="@mipmap/ic_launcher" />
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||
android:resource="@drawable/ic_launcher_foreground" />
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_color"
|
||||
android:resource="@color/ic_launcher_background" />
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||
android:value="tis_rms_activities_channel" />
|
||||
|
||||
@@ -68,8 +68,20 @@ class AlarmReceiver : BroadcastReceiver() {
|
||||
if (list.length() == 0) return
|
||||
|
||||
val highestOldId = prefs.getInt(KEY_LAST_ID, 0)
|
||||
var newHighestId = highestOldId
|
||||
if (highestOldId == 0) {
|
||||
// Seed KEY_LAST_ID on initial check to avoid spamming past notifications
|
||||
var maxId = 0
|
||||
for (i in 0 until list.length()) {
|
||||
val id = list.getJSONObject(i).optInt("id", 0)
|
||||
if (id > maxId) maxId = id
|
||||
}
|
||||
if (maxId > 0) {
|
||||
prefs.edit().putInt(KEY_LAST_ID, maxId).apply()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var newHighestId = highestOldId
|
||||
ensureChannel(context)
|
||||
val notifManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
@@ -81,8 +93,7 @@ class AlarmReceiver : BroadcastReceiver() {
|
||||
val isReadRaw = item.opt("is_read")
|
||||
val isRead = isReadRaw == true || isReadRaw == 1
|
||||
|
||||
val isNew = if (highestOldId == 0) !isRead else (id > highestOldId && !isRead)
|
||||
if (!isNew) continue
|
||||
if (id <= highestOldId || isRead) continue
|
||||
|
||||
val title = item.optString("title", "TIS RMS Notification")
|
||||
val message = item.optString("message", "")
|
||||
@@ -97,7 +108,7 @@ class AlarmReceiver : BroadcastReceiver() {
|
||||
)
|
||||
|
||||
val notification = Notification.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||
.setContentTitle(title)
|
||||
.setContentText(message)
|
||||
.setAutoCancel(true)
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
package com.teamsb.tis_rms
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
ensureNotificationChannel()
|
||||
AlarmScheduler.schedule(this)
|
||||
}
|
||||
|
||||
private fun ensureNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channelId = "tis_rms_activities_channel"
|
||||
val channelName = "Recent Activities"
|
||||
val channelDescription = "Notifications for recent activities and system events"
|
||||
val importance = NotificationManager.IMPORTANCE_HIGH
|
||||
val channel = NotificationChannel(channelId, channelName, importance).apply {
|
||||
description = channelDescription
|
||||
enableVibration(true)
|
||||
}
|
||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,13 +46,20 @@ void callbackDispatcher() {
|
||||
// 3. Find the last seen notification ID
|
||||
final highestOldId = prefs.getInt('last_seen_notification_id') ?? 0;
|
||||
|
||||
// On first run, seed last_seen_notification_id to latest ID to prevent alert storms of past items
|
||||
if (highestOldId == 0) {
|
||||
final ids = list.map((e) => e['id'] as int? ?? 0).toList();
|
||||
if (ids.isNotEmpty) {
|
||||
final newHighestId = ids.reduce(math.max);
|
||||
await prefs.setInt('last_seen_notification_id', newHighestId);
|
||||
}
|
||||
return Future.value(true);
|
||||
}
|
||||
|
||||
// Find new unread notifications
|
||||
final newNotes = list.where((e) {
|
||||
final id = e['id'] as int? ?? 0;
|
||||
final isRead = (e['is_read'] == 1 || e['is_read'] == true);
|
||||
if (highestOldId == 0) {
|
||||
return !isRead;
|
||||
}
|
||||
return id > highestOldId && !isRead;
|
||||
}).toList();
|
||||
|
||||
|
||||
@@ -14,22 +14,34 @@ import 'notification_service.dart';
|
||||
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||
try {
|
||||
await Firebase.initializeApp();
|
||||
final title = message.notification?.title ??
|
||||
message.data['title']?.toString() ??
|
||||
'TIS RMS';
|
||||
final body = message.notification?.body ??
|
||||
message.data['body']?.toString() ??
|
||||
'';
|
||||
int? notifId;
|
||||
if (message.data['id'] != null && message.data['id'].toString().isNotEmpty) {
|
||||
notifId = int.tryParse(message.data['id'].toString());
|
||||
final notifIdStr = message.data['id']?.toString();
|
||||
int? notifId = (notifIdStr != null && notifIdStr.isNotEmpty)
|
||||
? int.tryParse(notifIdStr)
|
||||
: null;
|
||||
|
||||
// Advance last_seen_notification_id so AlarmReceiver and Workmanager won't duplicate it
|
||||
if (notifId != null) {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final currentLastId = prefs.getInt('last_seen_notification_id') ?? 0;
|
||||
if (notifId > currentLastId) {
|
||||
await prefs.setInt('last_seen_notification_id', notifId);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
if (body.isNotEmpty) {
|
||||
await NotificationService().showNotification(
|
||||
id: notifId,
|
||||
title: title,
|
||||
body: body,
|
||||
);
|
||||
|
||||
// On Android, if message.notification != null, Google Play Services has already
|
||||
// presented the notification in the tray. Only present manually for data-only messages.
|
||||
if (message.notification == null) {
|
||||
final title = message.data['title']?.toString() ?? 'TIS RMS';
|
||||
final body = message.data['body']?.toString() ?? '';
|
||||
if (body.isNotEmpty) {
|
||||
await NotificationService().showNotification(
|
||||
id: notifId,
|
||||
title: title,
|
||||
body: body,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[FcmBackground] Handler error: $e');
|
||||
@@ -54,16 +66,28 @@ class FcmService {
|
||||
|
||||
// Foreground: show local notification banner (with deduplication)
|
||||
FirebaseMessaging.onMessage.listen((RemoteMessage message) async {
|
||||
final notifIdStr = message.data['id']?.toString();
|
||||
int? notifId = (notifIdStr != null && notifIdStr.isNotEmpty)
|
||||
? int.tryParse(notifIdStr)
|
||||
: null;
|
||||
|
||||
// Advance last_seen_notification_id so polling or background sync won't repeat it
|
||||
if (notifId != null) {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final currentLastId = prefs.getInt('last_seen_notification_id') ?? 0;
|
||||
if (notifId > currentLastId) {
|
||||
await prefs.setInt('last_seen_notification_id', notifId);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
final title = message.notification?.title ??
|
||||
message.data['title']?.toString() ??
|
||||
'TIS RMS';
|
||||
final body = message.notification?.body ??
|
||||
message.data['body']?.toString() ??
|
||||
'';
|
||||
int? notifId;
|
||||
if (message.data['id'] != null && message.data['id'].toString().isNotEmpty) {
|
||||
notifId = int.tryParse(message.data['id'].toString());
|
||||
}
|
||||
if (body.isNotEmpty) {
|
||||
await NotificationService().showNotification(
|
||||
id: notifId,
|
||||
@@ -72,6 +96,9 @@ class FcmService {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Eagerly register token with backend on startup if an active session exists
|
||||
registerToken();
|
||||
} catch (e) {
|
||||
debugPrint('[FcmService] Init error: $e');
|
||||
}
|
||||
@@ -96,13 +123,13 @@ class FcmService {
|
||||
|
||||
final dio = Dio(BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 5),
|
||||
connectTimeout: const Duration(seconds: 8),
|
||||
receiveTimeout: const Duration(seconds: 8),
|
||||
headers: {'Authorization': 'Bearer $jwtToken'},
|
||||
));
|
||||
|
||||
await dio.post('/notifications/fcm-token', data: {'token': token});
|
||||
debugPrint('[FcmService] Token registered');
|
||||
debugPrint('[FcmService] FCM token registered successfully with backend');
|
||||
} catch (e) {
|
||||
debugPrint('[FcmService] Token registration failed: $e');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/foundation.dart' show kIsWeb, debugPrint;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
class NotificationService {
|
||||
@@ -55,7 +56,6 @@ class NotificationService {
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
await androidImplementation?.requestNotificationsPermission();
|
||||
|
||||
const AndroidNotificationChannel channel = AndroidNotificationChannel(
|
||||
'tis_rms_activities_channel',
|
||||
@@ -66,6 +66,10 @@ class NotificationService {
|
||||
enableVibration: true,
|
||||
);
|
||||
await androidImplementation?.createNotificationChannel(channel);
|
||||
|
||||
try {
|
||||
await androidImplementation?.requestNotificationsPermission();
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
@@ -84,17 +88,32 @@ class NotificationService {
|
||||
|
||||
if (!_initialized) await initialize();
|
||||
|
||||
// Deduplication check (prevents duplicate triggers from FCM + Polling + Stream)
|
||||
final dedupeKey = '${id ?? ''}_${title.trim()}_${body.trim()}';
|
||||
// Deduplication check: key primarily by notification ID if available, otherwise by content
|
||||
final dedupeKey = id != null
|
||||
? 'id_$id'
|
||||
: 'msg_${title.trim()}_${body.trim()}';
|
||||
|
||||
if (_recentlyShown.contains(dedupeKey)) {
|
||||
debugPrint('[NotificationService] Suppressed duplicate notification: $dedupeKey');
|
||||
return;
|
||||
}
|
||||
_recentlyShown.add(dedupeKey);
|
||||
// Evict after 15 seconds
|
||||
Future.delayed(const Duration(seconds: 15), () {
|
||||
// Evict after 60 seconds (covers rapid polling cycles and AlarmManager checks)
|
||||
Future.delayed(const Duration(seconds: 60), () {
|
||||
_recentlyShown.remove(dedupeKey);
|
||||
});
|
||||
|
||||
// Advance last_seen_notification_id in SharedPreferences
|
||||
if (id != null) {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final current = prefs.getInt('last_seen_notification_id') ?? 0;
|
||||
if (id > current) {
|
||||
await prefs.setInt('last_seen_notification_id', id);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const AndroidNotificationDetails androidPlatformChannelSpecifics =
|
||||
AndroidNotificationDetails(
|
||||
'tis_rms_activities_channel', // id
|
||||
|
||||
Reference in new issue
Block a user