calling connection improvement

main_production_audio_video_call
Sikander Saleem 3 days ago
parent 6f4bc290c5
commit 777d8ccc71

@ -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';

@ -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<void> _disposeConnection() async {
final HubConnection? connection = callHubConnection;
final bool owns = _ownsConnection;
@ -83,6 +121,7 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin {
Future<void> 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<HubConnection> getHubConnection(String token) async {
Future<HubConnection> 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: <String, String>{
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<void>.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<Object?>? params) {
_cancelNoAnswerTimer();
_cancelConnectTimer();
if (params?.isNotEmpty ?? false) {
if (params![0] == "NO_ANSWER") {
onFailed?.call("No Answer, User did not answer your call.");

@ -190,7 +190,11 @@ class _ChatPageState extends State<ChatPage> {
@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();

Loading…
Cancel
Save