fix(notif): fix Android background push notification delivery and deduplicate alerts

This commit is contained in:
ssbg04 committed 2026-09-11 19:42:58 +08:00
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 });
+13 -3
View File
@@ -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();
+49 -22
View File
@@ -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