import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:signalr_netcore/http_connection_options.dart'; import 'package:signalr_netcore/hub_connection.dart'; import 'package:signalr_netcore/hub_connection_builder.dart'; import 'package:signalr_netcore/itransport.dart'; import 'package:test_sa/controllers/api_routes/urls.dart'; import 'package:test_sa/modules/cx_module/chat/api_client.dart'; import 'package:http/http.dart' as http; import 'package:test_sa/modules/cx_module/chat/calling/webrtc_service.dart'; 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 { print('$label: $e'); } catch (_) { print('$label: ${e.runtimeType}'); } } class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { final WebRTCService _webRTCService = WebRTCService(); WebRTCService get webRTCService => _webRTCService; bool isMuted = false; bool isSpeakerOn = false; bool isCameraOn = true; bool isPeerMuted = false; bool isPeerCameraOn = true; Timer? _callDurationTimer; Timer? _noAnswerTimer; Duration callDuration = Duration.zero; bool isLoading = false; String currentStatus = "Connecting..."; bool _ownsConnection = false; String? _currentCallId; void _setStatus(String status) { if (currentStatus == status) return; currentStatus = status; notifyListeners(); } void _startCallDurationTimer() { _callDurationTimer?.cancel(); callDuration = Duration.zero; _callDurationTimer = Timer.periodic( const Duration(seconds: 1), (_) { callDuration = Duration( seconds: callDuration.inSeconds + 1, ); notifyListeners(); }, ); } Future _disposeConnection() async { final HubConnection? connection = callHubConnection; final bool owns = _ownsConnection; callHubConnection = null; _ownsConnection = false; if (connection == null || !owns) return; try { await connection.stop(); } catch (e) { _logError('Error closing call SignalR connection', e); } } Future endSession() async { _cancelNoAnswerTimer(); stopListeners(); onFailed = null; onSuccess = null; isMuted = false; isPeerMuted = false; // `CallProvider` is a long-lived singleton (registered once in main.dart), // so without resetting these here, toggling the camera off near the end // of one video call would leak into the *next* call, making it look like // the camera defaults to "off" even though permission is granted. isCameraOn = true; isPeerCameraOn = true; currentStatus = "Connecting..."; _callDurationTimer?.cancel(); _callDurationTimer = null; callDuration = Duration.zero; await _webRTCService.dispose(); await _disposeConnection(); } @override void dispose() { _cancelNoAnswerTimer(); _callDurationTimer?.cancel(); _webRTCService.dispose(); _disposeConnection().then((_) {}).catchError((error) {}); super.dispose(); } /// 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 { // 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`. final String hubUrl = _urlWithExplicitPort( Uri.parse(URLs.chatHubUrlChat).replace(queryParameters: { "access_token": token, }), ); final HttpConnectionOptions httpOp = HttpConnectionOptions( transport: HttpTransportType.LongPolling, skipNegotiation: false, logMessageContent: false, accessTokenFactory: () async => token, requestTimeout: 30000, ); return HubConnectionBuilder().withUrl(hubUrl, options: httpOp).withAutomaticReconnect(retryDelays: [2000, 5000, 10000, 20000]).build(); } /// Writes the port into the url *textually*. /// /// Why this is not just `uri.replace(port: 443)`: `Uri` normalises a port away /// when it equals the scheme default, so `.replace(port: 443)` on an https uri /// serialises back to `https://host/...` with no port at all. signalr_netcore /// only does string surgery on this url (`url.replaceFirst('http', 'ws')`), so /// the port has to be present in the string to survive into the `wss://` form. /// Without it `Uri.port` is `0` for `ws`/`wss` (dart:io only knows defaults for /// http/https), which is exactly what gets echoed back as `https://host:0/...` /// in WebSocketException messages. static String _urlWithExplicitPort(Uri uri) { final int port = uri.hasPort ? uri.port : (uri.scheme == 'https' || uri.scheme == 'wss' ? 443 : 80); final String query = uri.query.isEmpty ? '' : '?${uri.query}'; return '${uri.scheme}://${uri.host}:$port${uri.path}$query'; } String get formattedCallDuration { final hours = callDuration.inHours; final minutes = callDuration.inMinutes.remainder(60); final seconds = callDuration.inSeconds.remainder(60); if (hours > 0) { return '${hours.toString().padLeft(2, '0')}:' '${minutes.toString().padLeft(2, '0')}:' '${seconds.toString().padLeft(2, '0')}'; } return '${minutes.toString().padLeft(2, '0')}:' '${seconds.toString().padLeft(2, '0')}'; } Future _initializeWebRTC() async { _webRTCService.onIceCandidate = _handleLocalIceCandidate; _webRTCService.onRemoteStream = (_) => notifyListeners(); _webRTCService.onIceConnectionStateChange = (RTCIceConnectionState state) { switch (state) { case RTCIceConnectionState.RTCIceConnectionStateConnected: case RTCIceConnectionState.RTCIceConnectionStateCompleted: if (currentStatus != "Connected") { _setStatus("Connected"); _startCallDurationTimer(); } break; case RTCIceConnectionState.RTCIceConnectionStateFailed: _setStatus("Call failed"); break; default: break; } }; _webRTCService.onCallEnded = () { if (currentStatus != "Call Ended" && currentStatus != "Call failed") { _setStatus("Call failed"); onFailed?.call("The call connection was lost."); } }; if (isVideo == true) { // Always start a fresh video call with both cameras assumed "on", // regardless of whatever state was left over from a previous session // on this singleton CallProvider (see endSession()). isCameraOn = true; isPeerCameraOn = true; await _webRTCService.initializeForVideoCall(); _webRTCService.setCameraEnabled(true); notifyListeners(); } else { await _webRTCService.initializeForAudioCall(); } } Future buildHubConnection(String employeeNumber, {HubConnection? hubC}) async { _setStatus("Connecting..."); try { if (hubC != null) { final bool reused = await _adoptExistingConnection(hubC); if (reused) return true; if (kDebugMode) print('Shared chat hub not usable (state: ${hubC.state}), opening a dedicated call connection.'); } await _disposeConnection(); final http.Response response = await ApiClient().postJsonForResponse( URLs.chatSdkToken, {"apiKey": URLs.chatApiKey, "employeeNumber": employeeNumber, "voIPToken": null}, ); if (response.statusCode != 200) { if (kDebugMode) print('Call token request failed: HTTP ${response.statusCode}'); return false; } final ChatLoginResponse chatResponse = ChatLoginResponse.fromJson(jsonDecode(response.body)); if (chatResponse.token == null || chatResponse.token!.isEmpty) { if (kDebugMode) print('Call token request returned an empty token.'); return false; } callHubConnection = await getHubConnection(chatResponse.token!); _ownsConnection = true; await callHubConnection!.start(); return callHubConnection?.state == HubConnectionState.Connected; } catch (e) { _logError('Error building call SignalR connection', e); await _disposeConnection(); return false; } } Future _adoptExistingConnection(HubConnection hubC) async { switch (hubC.state) { case HubConnectionState.Connected: callHubConnection = hubC; _ownsConnection = false; return true; case HubConnectionState.Connecting: case HubConnectionState.Reconnecting: final bool connected = await _waitUntilConnected(hubC); if (connected) { callHubConnection = hubC; _ownsConnection = false; } 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); } return false; default: // Disconnecting (or unknown) - do not touch it, build our own instead. return false; } } Future _waitUntilConnected(HubConnection hub, {Duration timeout = const Duration(seconds: 15)}) async { final DateTime deadline = DateTime.now().add(timeout); while (DateTime.now().isBefore(deadline)) { final HubConnectionState? state = hub.state; if (state == HubConnectionState.Connected) return true; if (state == HubConnectionState.Disconnected || state == HubConnectionState.Disconnecting) return false; await Future.delayed(const Duration(milliseconds: 200)); } return hub.state == HubConnectionState.Connected; } bool get isHubConnected => callHubConnection?.state == HubConnectionState.Connected; void initAudioCallListeners() { final HubConnection? hub = callHubConnection; if (hub == null) return; hub.on("OnCallAcceptedAsync", onCallAcceptedAsync); hub.on("OnIceCandidateAsync", onIceCandidateAsync); hub.on("OnOfferAsync", onOfferAsync); hub.on("OnAnswerOfferAsync", onAnswerOffer); hub.on("OnHangUpAsync", onHangUpAsync); hub.on("OnCallDeclinedAsync", onCallDeclinedAsync); hub.on('OnAudioToggle', onAudioToggle); } void initCallListeners() { final HubConnection? hub = callHubConnection; if (hub == null) return; initAudioCallListeners(); hub.on('OnCameraToggle', onCameraToggle); } String? senderN; String? recipientN; bool? isVideo; int moduleId = 1004; int? referenceID; Function(String message)? onFailed; Function(String message)? onSuccess; void startCall(String? senderN, String? recipientN, bool? isVideo, int? moduleId, int? referenceID, {Function(String message)? onFailed, Function(String message)? onSuccess}) async { _currentCallId = const Uuid().v4(); this.senderN = senderN; this.recipientN = recipientN; this.isVideo = isVideo; if (moduleId != null) this.moduleId = moduleId; this.referenceID = referenceID; this.onFailed = onFailed; this.onSuccess = onSuccess; if (senderN == null || senderN.isEmpty || recipientN == null || recipientN.isEmpty) { onFailed?.call("Call participants could not be resolved. Please reopen the chat and try again."); return; } try { await _initializeWebRTC(); await invoke(invokeMethod: "CallUserAsync", currentUserID: senderN, targetUserID: recipientN, isVideoCall: isVideo ?? false, data: [this.moduleId, referenceID]); _setStatus("Ringing..."); _startNoAnswerTimer(); } catch (e) { _logError('CallUserAsync failed', e); _setStatus("Call failed"); onFailed?.call("Could not reach the call service. Please check your connection and try again."); } } /// Starts (or restarts) the outgoing "no answer" timeout. If the callee /// hasn't accepted/declined within the window, we fail the call locally /// and best-effort notify the backend so the callee's ringing UI clears. void _startNoAnswerTimer() { _noAnswerTimer?.cancel(); _noAnswerTimer = Timer(const Duration(seconds: 45), _handleNoAnswerTimeout); } void _cancelNoAnswerTimer() { _noAnswerTimer?.cancel(); _noAnswerTimer = null; } Future _handleNoAnswerTimeout() async { _noAnswerTimer = null; // The call may have already connected/ended through another path. if (currentStatus != "Ringing...") return; _setStatus("Call failed"); try { if (senderN != null && recipientN != null) { await declineCall(senderN!, recipientN!, moduleId: moduleId, reason: 'NO_ANSWER'); } } catch (e) { _logError('Failed to notify backend of no-answer timeout', e); } onFailed?.call("No Answer, User did not answer your call."); } void acceptCall(String senderN, String recipientN, bool isVideo, {required int moduleId, Function(String message)? onFailed, Function(String message)? onSuccess}) async { this.senderN = senderN; this.recipientN = recipientN; this.isVideo = isVideo; this.onFailed = onFailed; this.onSuccess = onSuccess; try { _setStatus("Connecting..."); await invoke(invokeMethod: "AnswerCallAsync", currentUserID: senderN, targetUserID: recipientN, isVideoCall: isVideo, data: [this.moduleId]); } catch (e) { _logError('AnswerCallAsync failed', e); _setStatus("Call failed"); onFailed?.call("Could not join the call. Please try again."); } } Future declineCall(String senderN, String recipientN, {required int moduleId, String? reason}) async { try { await invoke( invokeMethod: "CallDeclinedAsync", currentUserID: senderN, targetUserID: recipientN, data: reason != null ? [moduleId, reason] : [moduleId], ); } catch (e) { _logError('CallDeclinedAsync failed', e); } } void endCall(String senderN, String recipientN, {required int moduleId}) async { try { await invoke(invokeMethod: "HangUpAsync", currentUserID: senderN, targetUserID: recipientN, data: [moduleId]); } catch (e) { _logError('HangUpAsync failed', e); } } Future toggleMute() async { if (senderN == null || recipientN == null) return; final newMutedState = !isMuted; _webRTCService.setMicrophoneMuted(newMutedState); isMuted = newMutedState; notifyListeners(); try { await invoke( invokeMethod: "AudioToggle", currentUserID: senderN!, targetUserID: recipientN!, ); } catch (e) { _logError('AudioToggle failed', e); } } Future toggleCamera() async { if (senderN == null || recipientN == null) return; final newCameraState = !isCameraOn; _webRTCService.setCameraEnabled(newCameraState); isCameraOn = newCameraState; notifyListeners(); try { await invoke( invokeMethod: "CameraToggle", currentUserID: senderN!, targetUserID: recipientN!, ); } catch (e) { _logError('CameraToggle failed', e); } } Future switchCamera() async { await _webRTCService.switchCamera(); } //// Listeners Methods //// Future onCallAcceptedAsync(List? params) async { _cancelNoAnswerTimer(); try { final RTCSessionDescription offer = await _webRTCService.createOffer(); if (offer.sdp == null || offer.sdp!.isEmpty) { throw Exception("Generated WebRTC offer SDP is empty"); } if (_currentCallId == null) { throw Exception("Current call ID is missing"); } await invoke( invokeMethod: "OfferAsync", currentUserID: senderN!, targetUserID: recipientN!, data: [ offer.sdp, _currentCallId, ], ); _setStatus("Connecting..."); } catch (e) { _logError('Failed to create/send WebRTC offer', e); _setStatus("Call failed"); onFailed?.call("Failed to establish the call."); } } Future toggleSpeaker() async { final newSpeakerState = !isSpeakerOn; await _webRTCService.setSpeakerphoneEnabled(newSpeakerState); isSpeakerOn = newSpeakerState; notifyListeners(); } Future onIceCandidateAsync(List? params) async { if (params == null || params.length < 2) return; try { final String? candidateJson = params[0]?.toString(); if (candidateJson == null || candidateJson.isEmpty) return; final Map candidateData = jsonDecode(candidateJson) as Map; final RTCIceCandidate candidate = RTCIceCandidate( candidateData['candidate']?.toString(), candidateData['sdpMid']?.toString(), candidateData['sdpMLineIndex'] is int ? candidateData['sdpMLineIndex'] as int : int.tryParse( candidateData['sdpMLineIndex']?.toString() ?? '', ), ); await _webRTCService.addIceCandidate(candidate); } catch (e) { _logError('Failed to process remote ICE candidate', e); } } Future onOfferAsync(List? params) async { if (params == null || params.length < 2) return; final String? offerSdp = params[0]?.toString(); final String? callId = params[1]?.toString(); if (offerSdp == null || offerSdp.isEmpty) return; if (callId == null || callId.isEmpty) return; try { _currentCallId = callId; await _initializeWebRTC(); final RTCSessionDescription answer = await _webRTCService.createAnswer(offerSdp); if (answer.sdp == null || answer.sdp!.isEmpty) { throw Exception("Generated WebRTC answer SDP is empty"); } await invoke( invokeMethod: "AnswerOfferAsync", currentUserID: senderN!, targetUserID: recipientN!, data: [ answer.sdp, _currentCallId, ], ); } catch (e) { _logError('Failed to handle incoming offer', e); } } Future onAnswerOffer(List? params) async { if (params == null || params.length < 2) return; final String? answerSdp = params[0]?.toString(); final String? callId = params[1]?.toString(); if (answerSdp == null || answerSdp.isEmpty) return; if (callId == null || callId.isEmpty) return; try { _currentCallId = callId; await _webRTCService.setRemoteAnswer(answerSdp); } catch (e) { _logError('Failed to set remote answer', e); } } void onHangUpAsync(List? params) { handleResponse(params); } void onCallDeclinedAsync(List? params) { handleResponse(params); } void _handleLocalIceCandidate(RTCIceCandidate candidate) { if (senderN == null || recipientN == null) return; if (_currentCallId == null) return; invoke( invokeMethod: "IceCandidateAsync", currentUserID: senderN!, targetUserID: recipientN!, data: [ jsonEncode({ "candidate": candidate.candidate, "sdpMid": candidate.sdpMid, "sdpMLineIndex": candidate.sdpMLineIndex, }), _currentCallId, ], ).catchError((error) { _logError('Failed to send ICE candidate', error); }); } void onAudioToggle(List? params) { isPeerMuted = !isPeerMuted; notifyListeners(); } void onCameraToggle(List? params) { isPeerCameraOn = !isPeerCameraOn; notifyListeners(); } //// Invoke Methods Future invoke({required String invokeMethod, required String currentUserID, required String targetUserID, bool isVideoCall = false, var data}) async { List args = []; if (data != null) { if (data is List) { data = data.map((e) => e.toString()).toList(); } else { data = data.toString(); } } if (invokeMethod == "AnswerCallAsync") { args = [currentUserID, targetUserID, ...data]; } else if (invokeMethod == 'CallDeclinedAsync') { args = [currentUserID, targetUserID, ...data]; } else if (invokeMethod == "CallUserAsync") { args = [currentUserID, targetUserID, isVideoCall]; } else if (invokeMethod == "HangUpAsync") { args = [currentUserID, targetUserID, ...data]; } else if (invokeMethod == "AudioToggle") { args = [currentUserID, targetUserID]; } else if (invokeMethod == "CameraToggle") { args = [currentUserID, targetUserID]; } else if (invokeMethod == "IceCandidateAsync") { args = [targetUserID, ...data]; } else if (invokeMethod == "OfferAsync") { args = [targetUserID, ...data]; } else if (invokeMethod == "AnswerOfferAsync") { args = [targetUserID, ...data]; } final HubConnection? hub = callHubConnection; if (hub == null || hub.state != HubConnectionState.Connected) { throw StateError("Call hub is not connected (state: ${hub?.state}). Cannot invoke $invokeMethod."); } await hub.invoke(invokeMethod, args: args); } void stopListeners() async { callHubConnection?.off('OnCallDeclinedAsync'); callHubConnection?.off('OnCallAcceptedAsync'); callHubConnection?.off('OnIceCandidateAsync'); callHubConnection?.off('OnAnswerOfferAsync'); callHubConnection?.off('OnOfferAsync'); callHubConnection?.off('OnHangUpAsync'); callHubConnection?.off('OnAudioToggle'); callHubConnection?.off('OnCameraToggle'); } handleResponse(List? params) { _cancelNoAnswerTimer(); if (params?.isNotEmpty ?? false) { if (params![0] == "NO_ANSWER") { onFailed?.call("No Answer, User did not answer your call."); return; } else if (params[0] == 'USER_BUSY') { onFailed?.call("The user is currently busy on another call"); } else if (params[0] == 'USER_DECLINE') { onFailed?.call("Call Declined, User has declined your call."); } else if (params[0] == 'USER_OFFLINE') { onFailed?.call("The user is currently offline"); } else { _setStatus("Call Ended"); onSuccess?.call(""); } } } }