You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
cloudsolutions-atoms/lib/controllers/notification/firebase_notification_mange...

609 lines
25 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'dart:math';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:flutter_callkit_incoming/entities/android_params.dart';
import 'package:flutter_callkit_incoming/entities/call_event.dart';
import 'package:flutter_callkit_incoming/entities/call_kit_params.dart';
import 'package:flutter_callkit_incoming/entities/ios_params.dart';
import 'package:flutter_callkit_incoming/entities/notification_params.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
import 'package:google_api_availability/google_api_availability.dart';
import 'package:huawei_push/huawei_push.dart' as h_push;
import 'package:permission_handler/permission_handler.dart' as ph;
import 'package:test_sa/controllers/notification/notification_manger.dart';
import 'package:test_sa/helper/utils.dart';
import 'package:test_sa/views/widgets/dialogs/dialog.dart';
import 'package:test_sa/models/device/device_transfer.dart';
import 'package:test_sa/models/new_models/gas_refill_model.dart';
import 'package:test_sa/modules/cm_module/cm_detail_page.dart';
import 'package:test_sa/modules/cx_module/chat/calling/call_provider.dart';
import 'package:test_sa/modules/cx_module/survey/survey_page.dart';
import 'package:test_sa/modules/pm_module/ppm_wo/ppm_details_page.dart';
import 'package:test_sa/modules/pm_module/recurrent_wo/recurrent_work_order_view.dart';
import 'package:test_sa/modules/tm_module/device_transfer/device_transfer_details.dart';
import 'package:test_sa/modules/tm_module/tasks/task_request_detail_view.dart';
import 'package:test_sa/modules/tm_module/gas_refill/gas_refill_details.dart';
import 'package:test_sa/new_views/common_widgets/default_app_bar.dart';
import 'package:test_sa/views/widgets/loaders/no_data_found.dart';
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
if (message.notification == null && message.data != null) {
// Killed app: main() does not run for an FCM background message, so the callkit background
// engine (registered in main) is not started and Accept/Decline taps would be dropped.
// Registering here starts it, so decline reaches `onBackgroundMessage` below.
if (Platform.isAndroid && message.data["type"] == "incoming_call") {
try {
await FlutterCallkitIncoming.onBackgroundMessage(onBackgroundMessage);
} catch (_) {}
}
handleDataMessage(message.data, true);
}
}
/// Android: receives callkit events while no UI listener is active (app killed / not on LandPage).
/// When the app is in the foreground, events go to `callingEvents` in land_page.dart instead.
@pragma('vm:entry-point')
Future<void> onBackgroundMessage(CallEvent event) async {
switch (event) {
case CallEventActionCallDecline(:final callKitParams):
await notifyCallerDeclined(callKitParams.extra ?? {});
default:
// Accept is handled when the app opens (splash_page.dart -> checkVoipCalling via activeCalls).
break;
}
}
/// Tells the caller the call was declined (same signalling as `_handleCallDeclined` in land_page.dart).
Future<void> notifyCallerDeclined(Map<String, dynamic> extra, {String? reason}) async {
final callee = extra["calleeEmployeeNumber"]?.toString() ?? '';
final caller = extra["callerEmployeeNumber"]?.toString() ?? '';
if (callee.isEmpty || caller.isEmpty) return;
final provider = CallProvider();
try {
if (await provider.buildHubConnection(callee)) {
await provider.declineCall(callee, caller, moduleId: int.tryParse(extra["moduleId"]?.toString() ?? '') ?? 0, reason: reason);
}
} catch (_) {
// Best effort - the caller side also times out.
} finally {
try {
await provider.endSession();
} catch (_) {}
}
}
class FirebaseNotificationManger {
static FirebaseMessaging messaging = FirebaseMessaging.instance;
static String? token;
static String? voipToken;
static Timer? _missedCallTimer;
static String? _activeMissedCallId;
/// iOS only. The VoIP token is registered natively in AppDelegate (PushKit) and stored
/// by flutter_callkit_incoming. It is sent to the backend as `voIPToken` on chat login.
static Future<void> refreshVoipToken() async {
if (!Platform.isIOS) return;
try {
final value = await FlutterCallkitIncoming.getDevicePushTokenVoIP();
if (value != null && value.isNotEmpty) voipToken = value;
} catch (_) {}
}
static void cancelMissedCallTimer([String? callId]) {
if (_activeMissedCallId == null) return;
if (callId != null && _activeMissedCallId != callId) return;
_missedCallTimer?.cancel();
_missedCallTimer = null;
_activeMissedCallId = null;
}
static final FirebaseNotificationManger _instance = FirebaseNotificationManger._internal();
FirebaseNotificationManger._internal();
factory FirebaseNotificationManger() => _instance;
static FirebaseNotificationManger getInstance() => _instance;
static Future<void> getToken() async {
NotificationSettings settings = await messaging.requestPermission(alert: true, announcement: false, badge: true, carPlay: false, criticalAlert: false, provisional: false, sound: true);
if (Platform.isAndroid) {
try {
if (!(await isGoogleServicesAvailable())) {
h_push.Push.enableLogger();
final result = await h_push.Push.setAutoInitEnabled(true);
h_push.Push.onMessageReceivedStream.listen(_onMessageReceived, onError: _onMessageReceiveError);
h_push.Push.getTokenStream.listen((hToken) {
// onToken(token);
// print("Huawei Token: ${hToken}");
token = hToken;
}, onError: (e) {
print("Huawei TokenError" + e.toString());
});
h_push.Push.getToken('');
} else {
if (settings.authorizationStatus == AuthorizationStatus.authorized) {
try {
token = await messaging.getToken();
} catch (ex) {}
}
}
} catch (ex) {
print("Notification Exception: " + ex.toString());
}
} else {
// VoIP token does not depend on notification permission.
await refreshVoipToken();
if (settings.authorizationStatus == AuthorizationStatus.authorized) {
try {
token = await messaging.getToken();
} catch (ex) {}
}
}
//print("pushToken:$token");
}
static void _onMessageReceived(h_push.RemoteMessage remoteMessage) {
print("onMessageReceivedStream:${remoteMessage.toMap()}");
}
static void _onMessageReceiveError(Object error) {
print("onMessageReceivedStream:${error.toString()}");
}
static Future<bool> isGoogleServicesAvailable() async {
try {
GooglePlayServicesAvailability availability = await GoogleApiAvailability.instance.checkGooglePlayServicesAvailability();
String status = availability.toString().split('.').last;
if (status == "success") {
return true;
}
return false;
} catch (ex) {
return false;
}
}
static void handleMessage(context, Map<String, dynamic> messageData) {
if (messageData["requestType"] != null && messageData["requestNumber"] != null) {
Widget? serviceClass;
String? transactionType = messageData["transactionType"]?.toString();
if (transactionType == null) {
return;
} else if (transactionType == "17" && messageData["requestType"] == "chat") {
int moduleId = int.parse(messageData["moduleId"].toString());
int requestNumber = int.parse(messageData["requestNumber"].toString());
switch (moduleId) {
case 1: // cm
serviceClass = CMDetailPage(requestId: requestNumber, moduleId: moduleId);
break;
case 2: // gas refill
serviceClass = GasRefillDetailsPage(
priority: messageData["priority"],
date: messageData["createdOn"],
moduleId: moduleId,
model: GasRefillModel(id: requestNumber),
);
break;
case 3: //transfer
serviceClass = DeviceTransferDetails(model: DeviceTransfer(id: requestNumber), moduleId: moduleId);
break;
case 6: // task
serviceClass = TaskRequestDetailsView(
taskId: requestNumber,
moduleId: moduleId,
// requestDetails: RequestsDetails(nameOfType: requestData?.nameOfType, status: requestData?.statusName, priority: requestData?.priorityName, date: requestData?.transactionDate, ),
);
// ServiceRequestDetailMain(requestId: int.parse(messageData["requestNumber"].toString()), moduleId: 1);
break;
default:
serviceClass = const Scaffold(appBar: DefaultAppBar(), body: Center(child: NoDataFound()));
}
Navigator.of(context).push(MaterialPageRoute(builder: (_) => serviceClass!));
return;
}
// PPM=1,
// ServiceRequestEngineer = 3,
// AssetTransfer=7,
// SparePartTransaction= 8,
// GasRefill=9,
// TechnicalRetirmentWO = 11,
// Recurrent = 12,
switch (transactionType) {
case "1":
serviceClass = PpmDetailsPage(requestId: int.parse(messageData["requestNumber"].toString()));
break;
//these three request are same corrective maintenance....
case "3":
serviceClass = CMDetailPage(requestId: int.parse(messageData["requestNumber"].toString()), moduleId: 1);
break;
case "8":
serviceClass = CMDetailPage(requestId: int.parse(messageData["requestNumber"].toString()), moduleId: 1);
break;
case "11":
serviceClass = CMDetailPage(requestId: int.parse(messageData["requestNumber"].toString()), moduleId: 1);
break;
case "7":
serviceClass = DeviceTransferDetails(model: DeviceTransfer(id: int.parse(messageData["requestNumber"].toString())), moduleId: 3);
break;
case "9":
serviceClass = GasRefillDetailsPage(
priority: messageData["priority"],
date: messageData["createdOn"],
moduleId: 2,
model: GasRefillModel(id: int.parse(messageData["requestNumber"].toString())),
);
break;
case "12":
serviceClass = RecurrentWorkOrderView(taskId: int.parse(messageData["requestNumber"].toString()));
case "17":
serviceClass = SurveyPage(surveyId: int.parse(messageData["requestNumber"].toString()));
//Didn't handle task request yet...
// case 6:
// serviceClass = TaskRequestDetailsView(
// taskId: int.parse(messageData["requestNumber"].toString()),
// requestDetails: RequestsDetails(nameOfType: messageData["sourceName"], status: messageData["statusName"], priority: messageData["priorityName"], date: messageData["createdDate"]));
// return;
default:
serviceClass = const Scaffold(appBar: DefaultAppBar(), body: Center(child: NoDataFound()));
}
// if (messageData["requestType"] == "Service request to engineer") {
// serviceClass = ServiceRequestDetailMain(requestId: messageData["requestNumber"] ?? '');
// } else if (messageData["requestType"] == "Gas Refill") {
// serviceClass = GasRefillDetailsPage(
// priority: messageData["priority"],
// date: messageData["createdOn"],
// model: GasRefillModel(id: int.parse(messageData["requestNumber"].toString())),
// );
// } else if (messageData["requestType"] == "Asset Transfer") {
// serviceClass = DeviceTransferDetails(model: DeviceTransfer(id: int.parse(messageData["requestNumber"].toString())));
// } else if (messageData["requestType"] == "PPM") {
// serviceClass = PpmDetailsPage(requestId: int.parse(messageData["requestNumber"].toString()));
// }
// else if (data["requestType"] == "WorkOrder") {
//
// }
Navigator.of(context).push(MaterialPageRoute(builder: (_) => serviceClass!));
}
}
initialized(BuildContext context) async {
// iOS incoming calls: VoIP pushes are handled natively in AppDelegate (PushKit -> CallKit
// via flutter_callkit_incoming). Accept/decline arrive through FlutterCallkitIncoming.onEvent
// (land_page.dart) or, from a cold start, FlutterCallkitIncoming.activeCalls() (splash_page.dart).
if (Platform.isIOS) {
await refreshVoipToken();
}
if (!(await isGoogleServicesAvailable()) && Platform.isAndroid) {
var initialNotification = await h_push.Push.getInitialNotification();
if (initialNotification != null) {
Map<String, dynamic> remoteData = Map<String, dynamic>.from(initialNotification["extras"] as Map);
handleMessage(context, remoteData);
}
h_push.Push.onNotificationOpenedApp.listen((message) {
try {
if (message is Map<String, dynamic>) {
Map<String, dynamic> remoteData = message;
remoteData = remoteData["extras"];
handleMessage(context, remoteData);
}
} catch (ex) {
print("parsingError:$ex");
}
}, onError: (e) => print("onNotificationOpenedApp Error${e.toString()}"));
return;
}
NotificationSettings settings;
try {
settings = await messaging.requestPermission(
alert: true,
announcement: false,
badge: true,
carPlay: false,
criticalAlert: false,
provisional: false,
sound: true,
);
} catch (error) {
return;
}
if (settings.authorizationStatus != AuthorizationStatus.authorized) {
return;
}
await FirebaseMessaging.instance.setAutoInitEnabled(true);
await FirebaseMessaging.instance.setForegroundNotificationPresentationOptions(alert: true, badge: true, sound: true);
FirebaseMessaging.instance.getInitialMessage().then((initialMessage) {
if (initialMessage != null) {
handleMessage(context, initialMessage.data);
}
});
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
print("onMessage:${message.toMap()}");
if (Platform.isAndroid) {
if (message.data["notificationType"] != 'NurseConfirmArrive') {
NotificationManger.showNotification(
title: message.notification?.title ?? "", subtext: message.notification?.body ?? "", hashcode: int.tryParse("1234" ?? "") ?? 1, payload: json.encode(message.data), context: context);
}
}
if (message.notification == null && message.data != null) {
handleDataMessage(message.data, false);
}
return;
});
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
handleMessage(context, message.data);
});
FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
}
}
Future<void> handleDataMessage(Map<String, dynamic> messageData, bool isFromBackground) async {
print("handleDataMessage:${messageData}");
// if (messageData["data"] != null) {
// messageData = messageData["data"];
// } else {
// return;
// }
if (messageData["type"] != null && messageData["type"] == "incoming_call") {
// if (notificationType == 'incoming_call' || transactionType == 'call') {
final callId = messageData['callId'] as String? ?? messageData['sessionId'] as String? ?? DateTime.now().millisecondsSinceEpoch.toString();
final callerId = messageData['callerId'] as String? ?? messageData['callerEmployeeNumber'] as String? ?? messageData['sourceUserId'] as String? ?? 'unknown';
final callerName = messageData['callerName'] as String? ?? messageData['callerUserName'] as String? ?? messageData['userName'] as String? ?? 'Unknown Caller';
final callerEmployeeNumber = messageData['callerEmployeeNumber'] as String? ?? callerId;
final calleeEmployeeNumber = messageData['calleeEmployeeNumber'] as String? ?? callerId;
final isVideoCall = () {
final value = messageData['isVideoCall'];
if (value is bool) return value;
if (value is String) return value.toLowerCase() == 'true';
if (messageData['callType'] == 'video') return true;
return false;
}();
String? moduleId = messageData['moduleId'] as String? ?? messageData['applicationId']?.toString();
if (messageData['moduleId'] != null && messageData['moduleId'].toString().isNotEmpty) {
moduleId = messageData['moduleId']?.toString();
} else if (messageData['applicationId'] != null && messageData['applicationId'].toString().isNotEmpty) {
moduleId = messageData['applicationId']?.toString();
}
final referenceId = messageData['referenceId'] as String?;
final conversationId = messageData['conversationId'] as String?;
print("handleDataMessage:moduleId:$moduleId");
// Android: any string id works. iOS: CallKit silently ignores non-UUID ids, so use a
// generated UUID for CallKit and keep the backend id in `extra.callId` (used everywhere else).
String callKitId = callId;
if (Platform.isIOS) {
// Already reported via a VoIP push (AppDelegate)? Don't ring twice.
final active = await FlutterCallkitIncoming.activeCalls();
if (active.any((c) => c.extra?['callId']?.toString() == callId)) return;
if (!_isUuid(callId)) callKitId = _uuidV4();
}
WidgetsFlutterBinding.ensureInitialized();
FirebaseNotificationManger.cancelMissedCallTimer();
FirebaseNotificationManger._activeMissedCallId = callId;
// Android 14+: on Accept, the plugin starts its "ongoing call" notification as a foreground
// service of type microphone (+camera for video). Without those runtime permissions Android
// throws a SecurityException and the app crashes before any Dart runs. When permissions are
// missing we skip that notification, so Accept just opens the app and the accept handler
// (land_page / splash_page) asks for permission instead.
final canShowOngoingNotification = !Platform.isAndroid || await hasCallPermissions(isVideoCall);
try {
final callKitParams = CallKitParams(
id: callKitId,
nameCaller: callerName,
appName: 'ATOMS',
avatar: '',
handle: callerEmployeeNumber,
type: isVideoCall ? 1 : 0,
// Slightly longer than the 30s missed-call timer below, so that timer (which also sends
// NO_ANSWER to the caller) always runs first; the plugin timeout is only a fallback.
duration: 32000,
missedCallNotification: const NotificationParams(
showNotification: true,
isShowCallback: false,
subtitle: 'Missed call',
callbackText: 'Call back',
),
callingNotification: NotificationParams(
showNotification: canShowOngoingNotification,
isShowCallback: true,
subtitle: 'Calling...',
callbackText: 'Hang Up',
),
extra: {
'callId': callId,
'callerId': callerId,
'callerEmployeeNumber': callerEmployeeNumber,
'calleeEmployeeNumber': calleeEmployeeNumber,
'callerName': callerName,
'moduleId': moduleId ?? '',
'referenceId': referenceId ?? '',
'conversationId': conversationId ?? '',
'isVideoCall': isVideoCall,
'timestamp': DateTime.now().toIso8601String(),
},
android: const AndroidParams(
// isCustomNotification: true,
isShowLogo: false,
ringtonePath: 'system_ringtone_default',
backgroundColor: '#0955fa',
backgroundUrl: '',
actionColor: '#4CAF50',
textColor: '#ffffff',
incomingCallNotificationChannelName: 'Incoming Calls',
missedCallNotificationChannelName: 'Missed Calls',
),
ios: const IOSParams(
iconName: 'CallKitLogo',
handleType: 'generic',
supportsVideo: true,
maximumCallGroups: 1,
maximumCallsPerCallGroup: 1,
audioSessionMode: 'default',
audioSessionActive: true,
audioSessionPreferredSampleRate: 44100.0,
audioSessionPreferredIOBufferDuration: 0.005,
supportsDTMF: true,
supportsHolding: false,
supportsGrouping: false,
supportsUngrouping: false,
ringtonePath: 'system_ringtone_default',
),
);
await FlutterCallkitIncoming.requestNotificationPermission({
// // "title": "Notification permission",
// // "rationaleMessagePermission": "Notification permission is required, to show notification.",
// // "postNotificationMessageRequired": "Notification permission is required, Please allow notification permission from setting."
});
await FlutterCallkitIncoming.canUseFullScreenIntent();
await FlutterCallkitIncoming.requestFullIntentPermission();
FlutterCallkitIncoming.unsilenceEvents();
FlutterCallkitIncoming.showCallkitIncoming(callKitParams);
FirebaseNotificationManger._missedCallTimer = Timer(const Duration(seconds: 30), () async {
if (FirebaseNotificationManger._activeMissedCallId != callId) return;
// This timer may live in a background isolate that cannot be cancelled from the
// decline/accept handlers, so re-check: only a call that is still ringing is "missed".
final stillRinging = (await FlutterCallkitIncoming.activeCalls()).any((c) => c.id == callKitId && !c.isAccepted);
if (!stillRinging) {
FirebaseNotificationManger.cancelMissedCallTimer(callId);
return;
}
await FlutterCallkitIncoming.endCall(callKitId);
await FlutterCallkitIncoming.endAllCalls();
await FlutterCallkitIncoming.showMissCallNotification(callKitParams);
try {
final notifyProvider = CallProvider();
final connected = await notifyProvider.buildHubConnection(calleeEmployeeNumber);
if (connected) {
await notifyProvider.declineCall(
calleeEmployeeNumber,
callerEmployeeNumber,
moduleId: int.tryParse(moduleId ?? '') ?? 0,
reason: 'NO_ANSWER',
);
}
await notifyProvider.endSession();
} catch (_) {
// Ignore - this is a best-effort notification only.
}
FirebaseNotificationManger.cancelMissedCallTimer(callId);
});
} catch (e, stackTrace) {
FirebaseNotificationManger.cancelMissedCallTimer(callId);
}
}
}
final RegExp _uuidPattern = RegExp(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$');
bool _isUuid(String value) => _uuidPattern.hasMatch(value);
/// Permissions needed to answer a call: microphone, plus camera for video.
///
/// Android 14+ (targetSdk 34+): when Accept is tapped, flutter_callkit_incoming natively starts a
/// foreground service of type `microphone` (and `camera` for video). Starting it without these
/// runtime permissions throws a SecurityException that kills the app before any Dart code runs,
/// so the Accept button must never be shown while they are missing.
Future<bool> hasCallPermissions(bool isVideoCall) async {
try {
if (!await ph.Permission.microphone.isGranted) return false;
if (isVideoCall && !await ph.Permission.camera.isGranted) return false;
return true;
} catch (_) {
return false;
}
}
/// Requests call permissions (needs a foreground Activity). Returns true when all are granted.
Future<bool> requestCallPermissions({bool includeCamera = true}) async {
try {
final result = await [ph.Permission.microphone, if (includeCamera) ph.Permission.camera].request();
return result.values.every((s) => s.isGranted);
} catch (_) {
return false;
}
}
/// Used when the user taps Accept: returns true if the call can proceed.
/// Asks for permission if needed; if it was permanently denied (Android stops showing the
/// system prompt), offers to open App Settings instead of failing silently.
Future<bool> ensureCallPermissionsOnAccept(BuildContext context, {required bool isVideoCall}) async {
if (await hasCallPermissions(isVideoCall)) return true;
if (await requestCallPermissions(includeCamera: isVideoCall)) return true;
if (!context.mounted) return false;
final what = isVideoCall ? "Camera and microphone" : "Microphone";
final permanentlyDenied = await ph.Permission.microphone.isPermanentlyDenied || (isVideoCall && await ph.Permission.camera.isPermanentlyDenied);
if (!context.mounted) return false;
if (permanentlyDenied) {
final openSettings = await showDialog<bool>(
context: context,
builder: (_) => AAlertDialog(
title: "Permission Required",
content: "$what access is turned off for ATOMS. Enable it in Settings to answer calls.",
button1Text: "Open Settings",
button2Text: "Cancel",
),
);
if (openSettings == true) await ph.openAppSettings();
} else {
Utils.showToast("$what permission is required to answer calls.");
}
return false;
}
/// RFC 4122 version-4 UUID (avoids adding a dependency for one call site).
String _uuidV4() {
final rnd = Random.secure();
final b = List<int>.generate(16, (_) => rnd.nextInt(256));
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
final h = b.map((x) => x.toRadixString(16).padLeft(2, '0')).join();
return '${h.substring(0, 8)}-${h.substring(8, 12)}-${h.substring(12, 16)}-${h.substring(16, 20)}-${h.substring(20)}';
}