diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 2bccb48c..e4711acf 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -67,6 +67,7 @@ fetch remote-notification voip + audio processing UILaunchStoryboardName diff --git a/lib/controllers/notification/firebase_notification_manger.dart b/lib/controllers/notification/firebase_notification_manger.dart index bf84f13e..5db8b611 100644 --- a/lib/controllers/notification/firebase_notification_manger.dart +++ b/lib/controllers/notification/firebase_notification_manger.dart @@ -2,6 +2,7 @@ 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'; @@ -14,7 +15,6 @@ 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:test_sa/controllers/notification/notification_manger.dart'; -import 'package:test_sa/extensions/string_extensions.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'; @@ -28,53 +28,71 @@ 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); } } +/// 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 onBackgroundMessage(CallEvent event1) async { - // This executes when a background/killed action happens - - - FlutterCallkitIncoming.onEvent.listen((CallEvent? event) { - switch (event!.eventName) { - case CallEventConstants.actionCallAccept: - // Handle logic when user hits Accept while app transitions to foreground - // var extraData = event?.body['extra']; - // Navigate to Call Screen - print("onBackgroundMessage:actionCallAccept:${event}"); - break; - case CallEventConstants.actionCallDecline: - // Notify backend that user declined - print("onBackgroundMessage:actionCallDecline:${event}"); - break; - default: - break; - } - }); - - switch (event1.eventName) { - case CallEventConstants.actionCallAccept: - "actionCallAccept".showToast; - break; - case CallEventConstants.actionCallDecline: - "actionCallDecline".showToast; - break; +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; } } +/// Tells the caller the call was declined (same signalling as `_handleCallDeclined` in land_page.dart). +Future _notifyCallerDeclined(Map extra) 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); + } + } 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 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; @@ -83,6 +101,15 @@ class FirebaseNotificationManger { _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); @@ -112,10 +139,11 @@ class FirebaseNotificationManger { 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(); - voipToken = await FlutterCallkitIncoming.getDevicePushTokenVoIP(); } catch (ex) {} } } @@ -254,8 +282,14 @@ class FirebaseNotificationManger { } } - static initialized(BuildContext context) async { -//TOD0 add platform check here also + 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) { @@ -366,18 +400,30 @@ Future handleDataMessage(Map messageData, bool isFromBack 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; try { final callKitParams = CallKitParams( - id: callId, + id: callKitId, nameCaller: callerName, appName: 'ATOMS', avatar: '', handle: callerEmployeeNumber, type: isVideoCall ? 1 : 0, - duration: 30000, + // 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, @@ -444,7 +490,15 @@ Future handleDataMessage(Map messageData, bool isFromBack FirebaseNotificationManger._missedCallTimer = Timer(const Duration(seconds: 30), () async { if (FirebaseNotificationManger._activeMissedCallId != callId) return; - await FlutterCallkitIncoming.endCall(callId); + // 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 { @@ -470,3 +524,18 @@ Future handleDataMessage(Map messageData, bool isFromBack } } } + +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); + +/// 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)}'; +} + diff --git a/lib/new_views/pages/land_page/land_page.dart b/lib/new_views/pages/land_page/land_page.dart index 9d90d8c5..0e4b51ba 100644 --- a/lib/new_views/pages/land_page/land_page.dart +++ b/lib/new_views/pages/land_page/land_page.dart @@ -139,7 +139,7 @@ class _LandPageState extends State { const MyAssetsPage(fromBottomBar: true), ]; if (isFCM) { - FirebaseNotificationManger.initialized(context); + FirebaseNotificationManger().initialized(context); callingEvents(context); NotificationManger.initialisation((notificationDetails) { FirebaseNotificationManger.handleMessage(context, json.decode(notificationDetails.payload!)); @@ -229,44 +229,15 @@ class _LandPageState extends State { case CallEventActionCallDecline(:final callKitParams): _handleCallDeclined(callKitParams); - case CallEventActionDidUpdateDevicePushTokenVoip(): - // TODO: Handle this case. - throw UnimplementedError(); - case CallEventActionCallIncoming(:final callKitParams): - // TODO: Handle this case. - throw UnimplementedError(); - case CallEventActionCallStart(): - // TODO: Handle this case. - throw UnimplementedError(); case CallEventActionCallEnded(:final callKitParams): _handleCallEnded(callKitParams); - case CallEventActionCallTimeout(): - // TODO: Handle this case. - throw UnimplementedError(); - case CallEventActionCallConnected(): - // TODO: Handle this case. - throw UnimplementedError(); - case CallEventActionCallCallback(): - // TODO: Handle this case. - throw UnimplementedError(); - case CallEventActionCallToggleHold(): - // TODO: Handle this case. - throw UnimplementedError(); - case CallEventActionCallToggleMute(): - // TODO: Handle this case. - throw UnimplementedError(); - case CallEventActionCallToggleDmtf(): - // TODO: Handle this case. - throw UnimplementedError(); - case CallEventActionCallToggleGroup(): - // TODO: Handle this case. - throw UnimplementedError(); - case CallEventActionCallToggleAudioSession(): - // TODO: Handle this case. - throw UnimplementedError(); - case CallEventActionCallCustom(): - // TODO: Handle this case. - throw UnimplementedError(); + + case CallEventActionDidUpdateDevicePushTokenVoip(): + FirebaseNotificationManger.refreshVoipToken(); + + // Informational events (several fire on every iOS call) - nothing to do. + default: + break; } }); } @@ -291,8 +262,14 @@ class _LandPageState extends State { void _handleCallAccepted(CallKitParams callKitParams) async { FirebaseNotificationManger.cancelMissedCallTimer(callKitParams.extra?["callId"] as String?); - FlutterCallkitIncoming.endCall(callKitParams.extra?["callId"]); - FlutterCallkitIncoming.endAllCalls(); + // iOS: keep the CallKit call alive for the whole call (keeps the app running and the mic + // available when answered from the lock screen); it is ended after the call page closes. + if (Platform.isIOS) { + await FlutterCallkitIncoming.setCallConnected(callKitParams.id); + } else { + FlutterCallkitIncoming.endCall(callKitParams.extra?["callId"]); + FlutterCallkitIncoming.endAllCalls(); + } final extra = callKitParams.extra ?? {}; final isVideoCallValue = extra["isVideoCall"]; @@ -311,6 +288,7 @@ class _LandPageState extends State { } if (!permissionGranted) { + if (Platform.isIOS) FlutterCallkitIncoming.endAllCalls(); try { final calPro = Provider.of(context, listen: false); final isConnected = await calPro.buildHubConnection(calleeEmployeeNumber); @@ -331,9 +309,10 @@ class _LandPageState extends State { } if (!mounted) return; - Navigator.of(context).push(MaterialPageRoute( + await Navigator.of(context).push(MaterialPageRoute( builder: (_) => isVideoCall ? VideoCallPage(sender: sender, recipient: recipient, moduleId: moduleId, referenceID: referenceID, isAccept: true) : AudioCallPage(sender: sender, recipient: recipient, moduleId: moduleId, referenceID: referenceID, isAccept: true))); + if (Platform.isIOS) FlutterCallkitIncoming.endAllCalls(); } } diff --git a/lib/new_views/pages/splash_page.dart b/lib/new_views/pages/splash_page.dart index a7d605f5..6c263765 100644 --- a/lib/new_views/pages/splash_page.dart +++ b/lib/new_views/pages/splash_page.dart @@ -18,6 +18,7 @@ import 'package:test_sa/controllers/providers/api/user_provider.dart'; import 'package:test_sa/controllers/providers/settings/setting_provider.dart'; import 'package:test_sa/extensions/widget_extensions.dart'; import 'package:test_sa/modules/cx_module/chat/calling/audio_call_page.dart'; +import 'package:test_sa/modules/cx_module/chat/calling/video_call_page.dart'; import 'package:test_sa/modules/cx_module/chat/model/chat_participant_model.dart'; import 'package:test_sa/new_views/app_style/app_color.dart'; import 'package:test_sa/new_views/common_widgets/app_lazy_loading.dart'; @@ -258,12 +259,16 @@ class _SplashPageState extends State { final String? callUuid = lastCall.id; await FlutterCallkitIncoming.setCallConnected(callUuid!); + final extra = lastCall.extra ?? {}; + final sender = Participants(employeeNumber: extra["calleeEmployeeNumber"]?.toString()); + final recipient = Participants(employeeNumber: extra["callerEmployeeNumber"]?.toString()); + final moduleId = int.tryParse(extra["moduleId"]?.toString() ?? '') ?? 0; + final referenceID = int.tryParse(extra["referenceId"]?.toString() ?? ''); + final isVideoCall = extra["isVideoCall"] == true || extra["isVideoCall"]?.toString().toLowerCase() == 'true'; await Navigator.of(context).push(MaterialPageRoute( - builder: (_) => AudioCallPage( - sender: Participants(employeeNumber: lastCall.extra?["calleeEmployeeNumber"]), - recipient: Participants(employeeNumber: lastCall.extra?["callerEmployeeNumber"]), - moduleId: int.parse(lastCall.extra!["moduleId"]), - isAccept: true))); + builder: (_) => isVideoCall + ? VideoCallPage(sender: sender, recipient: recipient, moduleId: moduleId, referenceID: referenceID, isAccept: true) + : AudioCallPage(sender: sender, recipient: recipient, moduleId: moduleId, referenceID: referenceID, isAccept: true))); FlutterCallkitIncoming.endCall(lastCall.extra?["callId"]); FlutterCallkitIncoming.endAllCalls(); } diff --git a/pubspec.lock b/pubspec.lock index 2e49098a..932a34e3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.12.32" + app_badge_control_flutter: + dependency: "direct main" + description: + name: app_badge_control_flutter + sha256: "6df220ab6236e1401488f6e756379dc555ebc76f509597b32dc5cd3bda3f7a6e" + url: "https://pub.dev" + source: hosted + version: "0.1.2" archive: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ca5b0e3e..5950da53 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -111,6 +111,7 @@ dependencies: sn_progress_dialog: ^1.2.0 flutter_webrtc: ^1.5.2 flutter_callkit_incoming: ^3.1.3 + app_badge_control_flutter: ^0.1.2 dev_dependencies: flutter_test: