diff --git a/lib/controllers/notification/firebase_notification_manger.dart b/lib/controllers/notification/firebase_notification_manger.dart index 4822ab32..580db5d2 100644 --- a/lib/controllers/notification/firebase_notification_manger.dart +++ b/lib/controllers/notification/firebase_notification_manger.dart @@ -71,7 +71,7 @@ Future notifyCallerDeclined(Map extra, {String? reason}) await provider.declineCall(callee, caller, moduleId: int.tryParse(extra["moduleId"]?.toString() ?? '') ?? 0, reason: reason); } } catch (_) { - // Best effort - the caller side also times out. + print("errorDecline:$_"); } finally { try { await provider.endSession(); @@ -86,6 +86,16 @@ class FirebaseNotificationManger { static Timer? _missedCallTimer; static String? _activeMissedCallId; + 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 { diff --git a/lib/modules/cx_module/chat/calling/audio_call_page.dart b/lib/modules/cx_module/chat/calling/audio_call_page.dart index 0d32ff55..be7445db 100644 --- a/lib/modules/cx_module/chat/calling/audio_call_page.dart +++ b/lib/modules/cx_module/chat/calling/audio_call_page.dart @@ -33,6 +33,7 @@ class _AudioCallPageState extends State { // Participants? participants; bool isDialogOpen = false; + bool _isClosing = false; @override void initState() { @@ -75,11 +76,14 @@ class _AudioCallPageState extends State { } void _closeCall() { + if (_isClosing) return; + _isClosing = true; callPro.endSession(); if (mounted) Navigator.pop(context); } void _showFailure(String message, {required String title}) { + if (_isClosing) return; callPro.endSession(); if (!mounted || isDialogOpen) return; isDialogOpen = true; @@ -98,6 +102,8 @@ class _AudioCallPageState extends State { okTitle: "Go Back", onTap: () { Navigator.pop(cxt); // close dialog + if (_isClosing) return; + _isClosing = true; if (mounted) Navigator.pop(context); // close call page }), ), @@ -211,6 +217,8 @@ class _AudioCallPageState extends State { }), ), callButton("calling/end_call", () async { + if (_isClosing) return; + _isClosing = true; callPro.endCall(widget.sender.employeeNumber ?? "", widget.recipient.employeeNumber ?? "", moduleId: widget.moduleId); Navigator.pop(context); }, isEndCall: true, size: 68), diff --git a/lib/modules/cx_module/chat/calling/video_call_page.dart b/lib/modules/cx_module/chat/calling/video_call_page.dart index ccc0fbdf..d14c9e62 100644 --- a/lib/modules/cx_module/chat/calling/video_call_page.dart +++ b/lib/modules/cx_module/chat/calling/video_call_page.dart @@ -31,6 +31,8 @@ class _VideoCallPageState extends State { bool isDialogOpen = false; bool isLocalVideoFullscreen = false; + bool _isClosing = false; + @override void initState() { super.initState(); @@ -69,11 +71,14 @@ class _VideoCallPageState extends State { } void _closeCall() { + if (_isClosing) return; + _isClosing = true; callPro.endSession(); if (mounted) Navigator.pop(context); } void _showFailure(String message, {required String title}) { + if (_isClosing) return; callPro.endSession(); if (!mounted || isDialogOpen) return; isDialogOpen = true; @@ -92,6 +97,8 @@ class _VideoCallPageState extends State { okTitle: "Go Back", onTap: () { Navigator.pop(cxt); + if (_isClosing) return; + _isClosing = true; if (mounted) Navigator.pop(context); }), ), @@ -308,6 +315,8 @@ class _VideoCallPageState extends State { ), callButton("calling/toggle_camera", () => callPro.switchCamera()), callButton("calling/end_call", () { + if (_isClosing) return; + _isClosing = true; callPro.endCall(widget.sender.employeeNumber ?? "", widget.recipient.employeeNumber ?? "", moduleId: widget.moduleId); Navigator.pop(context); }, isEndCall: true, size: 68), diff --git a/lib/new_views/pages/land_page/land_page.dart b/lib/new_views/pages/land_page/land_page.dart index c2e8eccd..040ebfd9 100644 --- a/lib/new_views/pages/land_page/land_page.dart +++ b/lib/new_views/pages/land_page/land_page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -51,6 +52,7 @@ class _LandPageState extends State { late List _pages; UserProvider? _userProvider; bool isFCM = true; + StreamSubscription? _callEventSubscription; @override void initState() { @@ -58,6 +60,12 @@ class _LandPageState extends State { super.initState(); } + @override + void dispose() { + _callEventSubscription?.cancel(); + super.dispose(); + } + void checkLocalAuth() async { if (await Provider.of(context, listen: false).auth.isDeviceSupported()) { SharedPreferences prefs = await SharedPreferences.getInstance(); @@ -217,7 +225,8 @@ class _LandPageState extends State { } callingEvents(BuildContext context) { - FlutterCallkitIncoming.onEvent.listen((CallEvent? event) { + _callEventSubscription?.cancel(); + _callEventSubscription = FlutterCallkitIncoming.onEvent.listen((CallEvent? event) { if (event == null) return; print("callingEvents:$event"); switch (event) { @@ -254,12 +263,23 @@ class _LandPageState extends State { } void _handleCallAccepted(CallKitParams callKitParams) async { + final callId = callKitParams.extra?["callId"]?.toString() ?? callKitParams.id; + // Ignore a duplicate accept for a call that is already being handled. + if (!FirebaseNotificationManger.beginAcceptedCall(callId)) return; + try { + await _openAcceptedCall(callKitParams); + } finally { + FirebaseNotificationManger.endAcceptedCall(); + } + } + + Future _openAcceptedCall(CallKitParams callKitParams) async { FirebaseNotificationManger.cancelMissedCallTimer(callKitParams.extra?["callId"] as String?); - // 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 { + // iOS: the user's Accept already answered the call in CallKit, which keeps it alive (app keeps + // running, mic stays available on the lock screen); it is ended after the call page closes. + // Do NOT call setCallConnected here: on iOS it issues another CXAnswerCallAction, which makes + // the plugin emit ACTION_CALL_ACCEPT again -> this handler runs again -> call page opens in a loop. + if (!Platform.isIOS) { FlutterCallkitIncoming.endCall(callKitParams.extra?["callId"]); FlutterCallkitIncoming.endAllCalls(); } diff --git a/lib/new_views/pages/splash_page.dart b/lib/new_views/pages/splash_page.dart index ef2e496e..b02b6cf5 100644 --- a/lib/new_views/pages/splash_page.dart +++ b/lib/new_views/pages/splash_page.dart @@ -259,25 +259,33 @@ class _SplashPageState extends State { final extra = lastCall.extra ?? {}; final isVideoCall = extra["isVideoCall"] == true || extra["isVideoCall"]?.toString().toLowerCase() == 'true'; - // Asks for mic (+camera) on Accept; offers Settings if permanently denied. - if (!await ensureCallPermissionsOnAccept(context, isVideoCall: isVideoCall)) { - await FlutterCallkitIncoming.endAllCalls(); - await notifyCallerDeclined(extra, reason: 'PERMISSION_DENIED'); - return; - } + // The same accepted call may also arrive via land_page's callkit listener - open it once. + if (!FirebaseNotificationManger.beginAcceptedCall(extra["callId"]?.toString() ?? callUuid ?? '')) return; + try { + // Asks for mic (+camera) on Accept; offers Settings if permanently denied. + if (!await ensureCallPermissionsOnAccept(context, isVideoCall: isVideoCall)) { + await FlutterCallkitIncoming.endAllCalls(); + await notifyCallerDeclined(extra, reason: 'PERMISSION_DENIED'); + return; + } - await FlutterCallkitIncoming.setCallConnected(callUuid!); - 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() ?? ''); - if (!mounted) return; - 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))); - FlutterCallkitIncoming.endCall(lastCall.extra?["callId"]); - FlutterCallkitIncoming.endAllCalls(); + // iOS: never call setCallConnected on an already-answered call - it issues another + // CXAnswerCallAction, which re-emits ACTION_CALL_ACCEPT and re-opens the call page. + if (!Platform.isIOS) await FlutterCallkitIncoming.setCallConnected(callUuid!); + 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() ?? ''); + if (!mounted) return; + 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))); + FlutterCallkitIncoming.endCall(lastCall.extra?["callId"]); + FlutterCallkitIncoming.endAllCalls(); + } finally { + FirebaseNotificationManger.endAcceptedCall(); + } } } else { await FlutterCallkitIncoming.endAllCalls();