|
|
|
|
@ -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.");
|
|
|
|
|
|