feat: implement Android Foreground Service for real-time LAN background notifications
This commit is contained in:
1 parent
f4cb43b29f
commit
4487f2872f
10 files changed
+242
-26
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user