diff --git a/backend/src/controllers/notificationController.js b/backend/src/controllers/notificationController.js
index 4bf1482..bcbcfa8 100644
--- a/backend/src/controllers/notificationController.js
+++ b/backend/src/controllers/notificationController.js
@@ -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 });
diff --git a/backend/src/services/fcmService.js b/backend/src/services/fcmService.js
index 7ccdeec..ced249b 100644
--- a/backend/src/services/fcmService.js
+++ b/backend/src/services/fcmService.js
@@ -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);
diff --git a/frontend/android/app/src/main/AndroidManifest.xml b/frontend/android/app/src/main/AndroidManifest.xml
index ebe5bc4..f5c5156 100644
--- a/frontend/android/app/src/main/AndroidManifest.xml
+++ b/frontend/android/app/src/main/AndroidManifest.xml
@@ -27,6 +27,12 @@
+
+
diff --git a/frontend/android/app/src/main/kotlin/com/teamsb/tis_rms/AlarmReceiver.kt b/frontend/android/app/src/main/kotlin/com/teamsb/tis_rms/AlarmReceiver.kt
index 2053329..455e115 100644
--- a/frontend/android/app/src/main/kotlin/com/teamsb/tis_rms/AlarmReceiver.kt
+++ b/frontend/android/app/src/main/kotlin/com/teamsb/tis_rms/AlarmReceiver.kt
@@ -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)
diff --git a/frontend/android/app/src/main/kotlin/com/teamsb/tis_rms/MainActivity.kt b/frontend/android/app/src/main/kotlin/com/teamsb/tis_rms/MainActivity.kt
index 9bc8263..e9b4118 100644
--- a/frontend/android/app/src/main/kotlin/com/teamsb/tis_rms/MainActivity.kt
+++ b/frontend/android/app/src/main/kotlin/com/teamsb/tis_rms/MainActivity.kt
@@ -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)
+ }
+ }
}
+
diff --git a/frontend/lib/core/services/background_sync_service.dart b/frontend/lib/core/services/background_sync_service.dart
index 8525658..e170e59 100644
--- a/frontend/lib/core/services/background_sync_service.dart
+++ b/frontend/lib/core/services/background_sync_service.dart
@@ -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();
diff --git a/frontend/lib/core/services/fcm_service.dart b/frontend/lib/core/services/fcm_service.dart
index 156a0d5..d041f3b 100644
--- a/frontend/lib/core/services/fcm_service.dart
+++ b/frontend/lib/core/services/fcm_service.dart
@@ -14,22 +14,34 @@ import 'notification_service.dart';
Future 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');
}
diff --git a/frontend/lib/core/services/notification_service.dart b/frontend/lib/core/services/notification_service.dart
index b42a761..380050c 100644
--- a/frontend/lib/core/services/notification_service.dart
+++ b/frontend/lib/core/services/notification_service.dart
@@ -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