You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
cloudsolutions-atoms/lib/modules/cx_module/chat/calling/call_provider.dart

677 lines
22 KiB
Dart

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<void> _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<void> 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<HubConnection> 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: <String, String>{
"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: <int>[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<void> _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<bool> 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<bool> _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<bool> _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<void>.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<void> _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<void> 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<void> 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<void> switchCamera() async {
await _webRTCService.switchCamera();
}
//// Listeners Methods ////
Future<void> onCallAcceptedAsync(List<Object?>? 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<void> toggleSpeaker() async {
final newSpeakerState = !isSpeakerOn;
await _webRTCService.setSpeakerphoneEnabled(newSpeakerState);
isSpeakerOn = newSpeakerState;
notifyListeners();
}
Future<void> onIceCandidateAsync(List<Object?>? params) async {
if (params == null || params.length < 2) return;
try {
final String? candidateJson = params[0]?.toString();
if (candidateJson == null || candidateJson.isEmpty) return;
final Map<String, dynamic> candidateData = jsonDecode(candidateJson) as Map<String, dynamic>;
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<void> onOfferAsync(List<Object?>? 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<void> onAnswerOffer(List<Object?>? 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<Object?>? params) {
handleResponse(params);
}
void onCallDeclinedAsync(List<Object?>? 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<Object?>? params) {
isPeerMuted = !isPeerMuted;
notifyListeners();
}
void onCameraToggle(List<Object?>? params) {
isPeerCameraOn = !isPeerCameraOn;
notifyListeners();
}
//// Invoke Methods
Future<void> invoke({required String invokeMethod, required String currentUserID, required String targetUserID, bool isVideoCall = false, var data}) async {
List<Object> 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<Object?>? 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("");
}
}
}
}