feat: implement Android Foreground Service for real-time LAN background notifications

This commit is contained in:
ssbg04 committed 2026-08-18 11:29:24 +08:00
1 parent f4cb43b29f
commit 4487f2872f
10 files changed
+242 -26

No files matched your search

+3
View File
@@ -171,6 +171,9 @@ Files backup/restore - mirror mode or zip -->
<!-- # ARCHIVES SCREEN
- remove background color and border of the filter icon button -->
<!-- # ANDROID APP PUSH NOTIFICATION
- notication only works when the app is open, make it work even the app is not opened -->
---
# TIS_RMS Server Manager (dont do)
@@ -10,16 +10,23 @@
android:required="false"/>
<uses-permission
android:name="android.permission.CAMERA"/>
<uses-permission
android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<application
android:usesCleartextTraffic="true"
android:label="TIS RMS"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<service
android:name="com.pravera.flutter_foreground_task.service.ForegroundService"
android:foregroundServiceType="dataSync"
android:stopWithTask="false"/>
<activity
android:name=".SplashActivity"
android:exported="true"
@@ -1,5 +1,6 @@
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
class ApiConstants {
static const int port = 18484;
@@ -18,6 +19,10 @@ class ApiConstants {
final newUrl = clean.endsWith('/api') ? clean : '$clean/api';
if (_baseUrl != newUrl) {
_baseUrl = newUrl;
SharedPreferences.getInstance().then((prefs) {
prefs.setString('server_url', newUrl);
}).catchError((_) {});
if (clearAuth) {
// Clear stored JWT token whenever user explicitly switches to a different server
const FlutterSecureStorage().delete(key: 'jwt_token');
@@ -1,60 +1,78 @@
import 'dart:math' as math;
import 'package:flutter/widgets.dart';
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:workmanager/workmanager.dart';
import '../network/api_constants.dart';
import 'notification_service.dart';
@pragma('vm:entry-point')
void callbackDispatcher() {
Workmanager().executeTask((task, inputData) async {
try {
WidgetsFlutterBinding.ensureInitialized();
// 1. Retrieve the saved base URL and Auth Token
final prefs = await SharedPreferences.getInstance();
final baseUrl = prefs.getString('server_url');
if (baseUrl == null) return true; // No server known, wait till next run
String rawUrl = prefs.getString('server_url') ?? ApiConstants.baseUrl;
final clean = rawUrl.replaceAll(RegExp(r'/+$'), '');
final baseUrl = clean.endsWith('/api') ? clean : '$clean/api';
const storage = FlutterSecureStorage();
final token = await storage.read(key: 'jwt_token');
if (token == null) return true; // Not logged in
if (token == null || token.isEmpty) {
return Future.value(true); // Not logged in
}
// 2. Fetch recent notifications from server
// 2. Fetch notifications from server (/notifications)
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 $token'},
),
);
final response = await dio.get('/notifications/recent');
final response = await dio.get('/notifications');
final list = (response.data as List).cast<Map<String, dynamic>>();
if (list.isNotEmpty) {
// 3. Find the last seen notification ID
final highestOldId = prefs.getInt('last_seen_notification_id') ?? 0;
// Find new ones
final newNotes = list
.where((e) => (e['id'] as int) > highestOldId)
.toList();
// 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();
if (newNotes.isNotEmpty) {
final notificationService = NotificationService();
await notificationService
.initialize(); // Ensure it's ready in this isolate
await notificationService.initialize();
// 4. Show local notification for each new one
for (var note in newNotes) {
final id = note['id'] as int?;
final title = note['title']?.toString() ?? 'TIS RMS Notification';
final message = note['message']?.toString() ?? '';
await notificationService.showNotification(
title: note['title'] ?? 'New Notification',
body: note['message'] ?? '',
id: id,
title: title,
body: message,
);
}
}
// 5. Update highest seen ID in prefs
final newHighestId = list.map((e) => e['id'] as int).reduce(math.max);
// 5. Update highest seen ID in prefs
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',
math.max(highestOldId, newHighestId),
@@ -63,8 +81,7 @@ void callbackDispatcher() {
}
return Future.value(true);
} catch (e) {
print('Background fetch failed: $e');
} catch (_) {
return Future.value(false); // Retries on false
}
});
@@ -0,0 +1,157 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'dart:math' as math;
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../network/api_constants.dart';
import 'notification_service.dart';
@pragma('vm:entry-point')
void startForegroundCallback() {
FlutterForegroundTask.setTaskHandler(ForegroundSyncHandler());
}
class ForegroundSyncHandler extends TaskHandler {
final FlutterSecureStorage _storage = const FlutterSecureStorage();
final NotificationService _notificationService = NotificationService();
@override
Future<void> onStart(DateTime timestamp, TaskStarter starter) async {
await _notificationService.initialize();
}
@override
Future<void> onRepeatEvent(DateTime timestamp) async {
try {
final prefs = await SharedPreferences.getInstance();
String rawUrl = prefs.getString('server_url') ?? ApiConstants.baseUrl;
final clean = rawUrl.replaceAll(RegExp(r'/+$'), '');
final baseUrl = clean.endsWith('/api') ? clean : '$clean/api';
final token = await _storage.read(key: 'jwt_token');
if (token == null || token.isEmpty) {
return;
}
final dio = Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 5),
headers: {'Authorization': 'Bearer $token'},
),
);
final response = await dio.get('/notifications');
final list = (response.data as List).cast<Map<String, dynamic>>();
if (list.isNotEmpty) {
final highestOldId = prefs.getInt('last_seen_notification_id') ?? 0;
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();
if (newNotes.isNotEmpty) {
await _notificationService.initialize();
for (var note in newNotes) {
final id = note['id'] as int?;
final title = note['title']?.toString() ?? 'TIS RMS Notification';
final message = note['message']?.toString() ?? '';
await _notificationService.showNotification(
id: id,
title: title,
body: message,
);
}
}
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',
math.max(highestOldId, newHighestId),
);
}
}
} catch (_) {
// Ignore transient network errors
}
}
@override
Future<void> onDestroy(DateTime timestamp) async {}
@override
void onReceiveData(Object data) {}
@override
void onNotificationButtonPressed(String id) {}
@override
void onNotificationPressed() {
FlutterForegroundTask.launchApp('/');
}
@override
void onNotificationDismissed() {}
}
class ForegroundSyncService {
static Future<void> init() async {
if (kIsWeb || !Platform.isAndroid) return;
FlutterForegroundTask.init(
androidNotificationOptions: AndroidNotificationOptions(
channelId: 'tis_rms_foreground_sync',
channelName: 'TIS RMS Background Sync',
channelDescription:
'Maintains real-time connection with local server for notifications',
channelImportance: NotificationChannelImportance.LOW,
priority: NotificationPriority.LOW,
),
iosNotificationOptions: const IOSNotificationOptions(
showNotification: false,
playSound: false,
),
foregroundTaskOptions: ForegroundTaskOptions(
eventAction: ForegroundTaskEventAction.repeat(10000), // Check every 10 seconds
autoRunOnBoot: true,
autoRunOnMyPackageReplaced: true,
allowWakeLock: true,
allowWifiLock: true,
),
);
}
static Future<void> start() async {
if (kIsWeb || !Platform.isAndroid) return;
if (await FlutterForegroundTask.isRunningService) {
return;
}
await FlutterForegroundTask.startService(
serviceId: 256,
notificationTitle: 'TIS RMS Sync Active',
notificationText: 'Connected to local server for real-time notifications',
notificationIcon: null,
callback: startForegroundCallback,
);
}
static Future<void> stop() async {
if (kIsWeb || !Platform.isAndroid) return;
if (await FlutterForegroundTask.isRunningService) {
await FlutterForegroundTask.stopService();
}
}
}
@@ -58,12 +58,21 @@ class NotificationService {
AndroidFlutterLocalNotificationsPlugin
>();
await androidImplementation?.requestNotificationsPermission();
const AndroidNotificationChannel channel = AndroidNotificationChannel(
'tis_rms_activities_channel',
'Recent Activities',
description: 'Notifications for recent activities and system events',
importance: Importance.max,
);
await androidImplementation?.createNotificationChannel(channel);
}
_initialized = true;
}
Future<void> showNotification({
int? id,
required String title,
required String body,
}) async {
@@ -87,9 +96,11 @@ class NotificationService {
android: androidPlatformChannelSpecifics,
);
final notificationId =
id ?? (DateTime.now().millisecondsSinceEpoch.remainder(100000));
await _flutterLocalNotificationsPlugin.show(
DateTime.now()
.millisecond, // use millisecond as unique ID for multiple notifications
notificationId,
title,
body,
platformChannelSpecifics,
+6 -3
View File
@@ -13,6 +13,7 @@ import 'core/theme/app_theme.dart'; // Add this import
import 'core/constants/app_colors.dart';
import 'core/services/notification_service.dart';
import 'core/services/background_sync_service.dart';
import 'core/services/foreground_sync_service.dart';
import 'ui/providers/theme_provider.dart';
import 'ui/shared/widgets/inactivity_wrapper.dart';
import 'package:workmanager/workmanager.dart';
@@ -67,15 +68,17 @@ void main() async {
}
await NotificationService().initialize();
await ForegroundSyncService.init();
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
Workmanager().initialize(callbackDispatcher, isInDebugMode: false);
// Register periodic task
Workmanager().initialize(callbackDispatcher);
// Register periodic background sync task
Workmanager().registerPeriodicTask(
"1",
"tis_rms_background_sync",
"background_sync_task",
frequency: const Duration(minutes: 15),
constraints: Constraints(networkType: NetworkType.connected),
existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
);
}
@@ -8,6 +8,7 @@ import 'student_provider.dart' hide academicYearsProvider;
import 'navigation_provider.dart';
import 'reports_provider.dart';
import 'connected_users_provider.dart';
import '../../core/services/foreground_sync_service.dart';
final authRepositoryProvider = Provider<AuthRepository>(
(ref) => AuthRepository(),
@@ -38,6 +39,7 @@ class AuthNotifier extends AsyncNotifier<UserModel?> {
);
state = AsyncData(user);
ref.read(heartbeatServiceProvider).start(user);
ForegroundSyncService.start();
return true;
} catch (e, st) {
state = AsyncError(e, st);
@@ -53,6 +55,7 @@ class AuthNotifier extends AsyncNotifier<UserModel?> {
if (user != null) {
state = AsyncData(user);
ref.read(heartbeatServiceProvider).start(user);
ForegroundSyncService.start();
}
return user;
} catch (_) {
@@ -62,6 +65,7 @@ class AuthNotifier extends AsyncNotifier<UserModel?> {
Future<void> logout() async {
ref.read(heartbeatServiceProvider).stop();
ForegroundSyncService.stop();
final repository = ref.read(authRepositoryProvider);
await repository.logout();
state = const AsyncData(null);
+8
View File
@@ -350,6 +350,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_foreground_task:
dependency: "direct main"
description:
name: flutter_foreground_task
sha256: "206017ee1bf864f34b8d7bce664a172717caa21af8da23f55866470dfe316644"
url: "https://pub.dev"
source: hosted
version: "8.17.0"
flutter_holo_date_picker:
dependency: "direct main"
description:
+1
View File
@@ -62,6 +62,7 @@ dependencies:
sheetifye: ^1.1.0
flutter_local_notifications: ^17.2.2
workmanager: ^0.9.0+3
flutter_foreground_task: ^8.17.0
desktop_drop: ^0.7.1
dev_dependencies: