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 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); } } @pragma('vm:entry-point') Future backgroundMessageCallback(h_push.RemoteMessage remoteMessage) async { final payload = huaweiMessageData(remoteMessage); if (payload == null || payload["type"] != "incoming_call") return; // Start the callkit background engine so a Decline tapped while killed reaches the caller. try { await FlutterCallkitIncoming.onBackgroundMessage(onBackgroundMessage); } catch (_) {} await handleDataMessage(payload, true); } Map? huaweiMessageData(h_push.RemoteMessage remoteMessage) { try { Map? map; final raw = remoteMessage.data; if (raw != null && raw.isNotEmpty) { final decoded = json.decode(raw); if (decoded is Map) map = Map.from(decoded); } map ??= remoteMessage.dataOfMap == null ? null : Map.from(remoteMessage.dataOfMap!); if (map == null) return null; if (map["type"] == null && map["data"] != null) { final inner = map["data"] is String ? json.decode(map["data"]) : map["data"]; if (inner is Map) map = Map.from(inner); } return { for (final e in map.entries) if (e.value != null) e.key: e.value is String ? e.value : (e.value is Map || e.value is List ? json.encode(e.value) : e.value.toString()), }; } catch (e) { print("HMS data parse error: $e"); return null; } } @pragma('vm:entry-point') Future 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; } } class CallKitEvents { static StreamSubscription? _subscription; /// Set by LandPage while it is mounted; receives every event after this class handled it. static void Function(CallEvent event)? uiHandler; static void startIOS() { if (!Platform.isIOS || _subscription != null) return; _subscription = FlutterCallkitIncoming.onEvent.listen((CallEvent? event) { if (event == null) return; if (event case CallEventActionCallDecline(:final callKitParams)) { // Works without UI or login state; AppDelegate keeps the app alive while it runs. notifyCallerDeclined(callKitParams.extra ?? {}); } uiHandler?.call(event); }); } } /// Tells the caller the call was declined (same signalling as `_handleCallDeclined` in land_page.dart). Future notifyCallerDeclined(Map 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; static StreamSubscription? _hmsMessageSubscription; static String? _acceptedCallInProgress; /// Returns false if this call is already being handled - the caller must then do nothing. static bool beginAcceptedCall(String callId) { if (_acceptedCallInProgress == callId) return false; _acceptedCallInProgress = callId; return true; } static void endAcceptedCall() => _acceptedCallInProgress = null; /// 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 refreshVoipToken() async { if (!Platform.isIOS) return; try { final value = await FlutterCallkitIncoming.getDevicePushTokenVoIP(); if (value != null && value.isNotEmpty) voipToken = value; print("voipToken:$voipToken"); } 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 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); listenHmsForegroundMessages(); 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 Stream? _hmsMessageStream; static Stream get hmsMessageStream => _hmsMessageStream ??= h_push.Push.onMessageReceivedStream.asBroadcastStream(); static void listenHmsForegroundMessages() { _hmsMessageSubscription ??= hmsMessageStream.listen(_onMessageReceived, onError: _onMessageReceiveError); } static void _onMessageReceived(h_push.RemoteMessage remoteMessage) { final payload = huaweiMessageData(remoteMessage); if (payload != null && payload["type"] == "incoming_call") { handleDataMessage(payload, false); } } static void _onMessageReceiveError(Object error) { print("onMessageReceivedStream:${error.toString()}"); } static Future 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 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) { listenHmsForegroundMessages(); var initialNotification = await h_push.Push.getInitialNotification(); if (initialNotification != null) { Map remoteData = Map.from(initialNotification["extras"] as Map); handleMessage(context, remoteData); } h_push.Push.onNotificationOpenedApp.listen((message) { try { if (message is Map) { Map 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 handleDataMessage(Map 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, // Same as AppDelegate.swift: no app-owned audio session while ringing, so the hardware // buttons control the CallKit call (silence / double-press decline). configureAudioSession: false, 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 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 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 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( 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.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)}'; }