From 777d8ccc7152c2fd776be5cbccdf0b929480cfc7 Mon Sep 17 00:00:00 2001 From: Sikander Saleem Date: Tue, 20 Oct 2026 12:35:36 +0300 Subject: [PATCH] calling connection improvement --- .../firebase_notification_manger.dart | 1 + .../cx_module/chat/calling/call_provider.dart | 128 ++++++++++++++---- lib/modules/cx_module/chat/chat_page.dart | 6 +- 3 files changed, 109 insertions(+), 26 deletions(-) diff --git a/lib/controllers/notification/firebase_notification_manger.dart b/lib/controllers/notification/firebase_notification_manger.dart index 08f2204f..54ed638b 100644 --- a/lib/controllers/notification/firebase_notification_manger.dart +++ b/lib/controllers/notification/firebase_notification_manger.dart @@ -22,6 +22,7 @@ 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/chat/chat_provider.dart' show chatHubConnection; 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'; diff --git a/lib/modules/cx_module/chat/calling/call_provider.dart b/lib/modules/cx_module/chat/calling/call_provider.dart index f069cc26..f8e1e222 100644 --- a/lib/modules/cx_module/chat/calling/call_provider.dart +++ b/lib/modules/cx_module/chat/calling/call_provider.dart @@ -16,8 +16,6 @@ import 'package:uuid/uuid.dart'; import '../chat_provider.dart'; import '../model/chat_login_response_model.dart'; -HubConnection? callHubConnection; - void _logError(String label, Object e) { if (!kDebugMode) return; try { @@ -32,6 +30,19 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { WebRTCService get webRTCService => _webRTCService; + /// Per instance (was a global): the short-lived providers used for decline / missed-call + /// notifications must never replace or close the connection of the call in progress. + HubConnection? callHubConnection; + + /// Fails the call if media is not connected in time after accept/offer, instead of + /// leaving the user on "Connecting..." forever. + Timer? _connectTimer; + static const Duration _connectTimeout = Duration(seconds: 30); + + /// Only remove handlers this instance added: a short-lived decline/missed-call provider may + /// share the chat connection with a call in progress and must not strip its handlers. + bool _listenersRegistered = false; + bool isMuted = false; bool isSpeakerOn = false; bool isCameraOn = true; @@ -68,6 +79,33 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { ); } + void _startConnectTimer() { + _connectTimer?.cancel(); + _connectTimer = Timer(_connectTimeout, () { + _connectTimer = null; + if (currentStatus == "Connected" || currentStatus == "Call Ended") return; + _failCall("Could not connect the call. Please check your internet connection and try again."); + }); + } + + void _cancelConnectTimer() { + _connectTimer?.cancel(); + _connectTimer = null; + } + + /// Single place to fail an active call: updates status, tells the other side (best effort, + /// same HangUpAsync as the End button) so it doesn't stay stuck either, and notifies the UI once. + void _failCall(String message) { + _cancelConnectTimer(); + _cancelNoAnswerTimer(); + if (currentStatus == "Call failed" || currentStatus == "Call Ended") return; + _setStatus("Call failed"); + if (senderN != null && recipientN != null) { + endCall(senderN!, recipientN!, moduleId: moduleId); + } + onFailed?.call(message); + } + Future _disposeConnection() async { final HubConnection? connection = callHubConnection; final bool owns = _ownsConnection; @@ -83,6 +121,7 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { Future endSession() async { _cancelNoAnswerTimer(); + _cancelConnectTimer(); stopListeners(); onFailed = null; onSuccess = null; @@ -99,6 +138,13 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { _callDurationTimer?.cancel(); _callDurationTimer = null; callDuration = Duration.zero; + // Closing the peer connection fires onCallEnded/ICE callbacks - detach them first so a + // normal hang-up is not reported (or signalled to the other side) as a failure. + // They are set again by _initializeWebRTC() for the next call. + _webRTCService.onCallEnded = null; + _webRTCService.onIceConnectionStateChange = null; + _webRTCService.onIceCandidate = null; + _webRTCService.onRemoteStream = null; await _webRTCService.dispose(); await _disposeConnection(); } @@ -106,6 +152,7 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { @override void dispose() { _cancelNoAnswerTimer(); + _cancelConnectTimer(); _callDurationTimer?.cancel(); _webRTCService.dispose(); _disposeConnection().then((_) {}).catchError((error) {}); @@ -115,12 +162,17 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { /// See [ChatProvider.buildHubConnection] for why a `:0` port can show up in /// SignalR websocket errors - it is a `dart:io` artifact (Uri.port == 0 for /// the `wss` scheme), not a real port. Read the trailing HTTP status instead. - Future getHubConnection(String token) async { + Future getHubConnection(String token, {int? userId}) async { // Encode the JWT properly and also pass it as an Authorization header: // on mobile signalr_netcore ignores the query token for the websocket // handshake and relies on `accessTokenFactory`. + // `UserId`/`source` are the same as ChatProvider.getHubConnection (the connection that calls + // already work on): they let the server route OfferAsync / IceCandidateAsync to this + // connection when no chat is open (accept from dashboard / background / killed). final String hubUrl = _urlWithExplicitPort( Uri.parse(URLs.chatHubUrlChat).replace(queryParameters: { + if (userId != null) "UserId": "$userId", + if (userId != null) "source": "Desktop", "access_token": token, }), ); @@ -177,6 +229,7 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { case RTCIceConnectionState.RTCIceConnectionStateConnected: case RTCIceConnectionState.RTCIceConnectionStateCompleted: if (currentStatus != "Connected") { + _cancelConnectTimer(); // The OS resets the audio route to the earpiece once media // actually starts flowing, so re-apply it on connect. _applyDefaultAudioRoute(); @@ -185,7 +238,9 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { } break; case RTCIceConnectionState.RTCIceConnectionStateFailed: - _setStatus("Call failed"); + // Used to only set the status; onCallEnded then skipped onFailed because the status + // was already "Call failed", leaving the user on a dead call screen. + _failCall("The call connection was lost."); break; default: break; @@ -193,9 +248,8 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { }; _webRTCService.onCallEnded = () { - if (currentStatus != "Call Ended" && currentStatus != "Call failed") { - _setStatus("Call failed"); - onFailed?.call("The call connection was lost."); + if (currentStatus != "Call Ended") { + _failCall("The call connection was lost."); } }; @@ -247,7 +301,16 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { bool isHuaweiDevice() => platform == "HUAWEI"; final http.Response response = await ApiClient().postJsonForResponse( URLs.chatSdkToken, - {"apiKey": URLs.chatApiKey, "employeeNumber": employeeNumber, "isHuaweiDevice": isHuaweiDevice(), "platform": platform, "voIPToken": FirebaseNotificationManger.voipToken}, + { + "apiKey": URLs.chatApiKey, + "employeeNumber": employeeNumber, + "isHuaweiDevice": isHuaweiDevice(), + "platform": platform, + "voIPToken": FirebaseNotificationManger.voipToken, + // Same value the chat login sends. Only included when known (null in a background + // isolate), so a valid stored push token is never cleared. + if (FirebaseNotificationManger.token != null && FirebaseNotificationManger.token!.isNotEmpty) "deviceToken": FirebaseNotificationManger.token, + }, ); if (response.statusCode != 200) { if (kDebugMode) print('Call token request failed: HTTP ${response.statusCode}'); @@ -260,9 +323,16 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { return false; } - callHubConnection = await getHubConnection(chatResponse.token!); + callHubConnection = await getHubConnection(chatResponse.token!, userId: chatResponse.userId); _ownsConnection = true; - await callHubConnection!.start(); + try { + await callHubConnection!.start(); + } catch (e) { + // One retry - a network hiccup right after the app wakes up is common. + _logError('Call hub start failed, retrying once', e); + await Future.delayed(const Duration(seconds: 1)); + await callHubConnection!.start(); + } return callHubConnection?.state == HubConnectionState.Connected; } catch (e) { _logError('Error building call SignalR connection', e); @@ -286,18 +356,9 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { } return connected; case HubConnectionState.Disconnected: - // Chat side dropped it - we can legally restart it, but we do not own it - // so we must not stop it when the call ends. - try { - await hubC.start(); - if (hubC.state == HubConnectionState.Connected) { - callHubConnection = hubC; - _ownsConnection = false; - return true; - } - } catch (e) { - _logError('Could not restart shared chat hub for call', e); - } + // A stopped chat connection (left over from a closed chat page) has an old token and no + // conversation joined - restarting it made calls connect or hang at random. + // A fresh dedicated connection is reliable, so build that instead. return false; default: // Disconnecting (or unknown) - do not touch it, build our own instead. @@ -321,6 +382,7 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { void initAudioCallListeners() { final HubConnection? hub = callHubConnection; if (hub == null) return; + _listenersRegistered = true; hub.on("OnCallAcceptedAsync", onCallAcceptedAsync); hub.on("OnIceCandidateAsync", onIceCandidateAsync); hub.on("OnOfferAsync", onOfferAsync); @@ -376,7 +438,11 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { /// and best-effort notify the backend so the callee's ringing UI clears. void _startNoAnswerTimer() { _noAnswerTimer?.cancel(); - _noAnswerTimer = Timer(const Duration(seconds: 45), _handleNoAnswerTimeout); + // 60s, not 45s: after tapping Accept the callee may still be answering the mic/camera + // permission prompts (first call) and connecting before AnswerCallAsync reaches us; 45s + // expired in that window and dropped a call the callee had accepted. Unanswered calls are + // unaffected - the callee's own 30s ring timeout still sends NO_ANSWER at ~30s. + _noAnswerTimer = Timer(const Duration(seconds: 60), _handleNoAnswerTimeout); } void _cancelNoAnswerTimer() { @@ -407,13 +473,19 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { this.senderN = senderN; this.recipientN = recipientN; this.isVideo = isVideo; + // Was ignored (a stale/default value was sent). A push without moduleId parses to 0 - + // keep the previous default in that case. + if (moduleId > 0) this.moduleId = moduleId; this.onFailed = onFailed; this.onSuccess = onSuccess; try { _setStatus("Connecting..."); + // Caller side: accepted, but media may still never connect (network/TURN) - don't hang. + _startConnectTimer(); await invoke(invokeMethod: "AnswerCallAsync", currentUserID: senderN, targetUserID: recipientN, isVideoCall: isVideo, data: [this.moduleId]); } catch (e) { _logError('AnswerCallAsync failed', e); + _cancelConnectTimer(); _setStatus("Call failed"); onFailed?.call("Could not join the call. Please try again."); } @@ -508,10 +580,11 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { ], ); _setStatus("Connecting..."); + // Caller side: accepted, but media may still never connect (network/TURN) - don't hang. + _startConnectTimer(); } catch (e) { _logError('Failed to create/send WebRTC offer', e); - _setStatus("Call failed"); - onFailed?.call("Failed to establish the call."); + _failCall("Failed to establish the call."); } } @@ -580,6 +653,8 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { ); } catch (e) { _logError('Failed to handle incoming offer', e); + // Used to only log, leaving the callee on "Connecting..." (e.g. mic/camera unavailable). + _failCall("Failed to establish the call."); } } @@ -676,6 +751,8 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { } void stopListeners() async { + if (!_listenersRegistered) return; + _listenersRegistered = false; callHubConnection?.off('OnCallDeclinedAsync'); callHubConnection?.off('OnCallAcceptedAsync'); callHubConnection?.off('OnIceCandidateAsync'); @@ -688,6 +765,7 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { handleResponse(List? params) { _cancelNoAnswerTimer(); + _cancelConnectTimer(); if (params?.isNotEmpty ?? false) { if (params![0] == "NO_ANSWER") { onFailed?.call("No Answer, User did not answer your call."); diff --git a/lib/modules/cx_module/chat/chat_page.dart b/lib/modules/cx_module/chat/chat_page.dart index f45c21e6..acb289ee 100644 --- a/lib/modules/cx_module/chat/chat_page.dart +++ b/lib/modules/cx_module/chat/chat_page.dart @@ -190,7 +190,11 @@ class _ChatPageState extends State { @override void dispose() { - chatHubConnection?.stop(); + // Also clear the shared reference: a stopped connection left here was later picked up by an + // incoming call (accepted from the dashboard). + final hub = chatHubConnection; + chatHubConnection = null; + hub?.stop(); playerController.dispose(); recorderController.dispose(); super.dispose();