-
Notifications
You must be signed in to change notification settings - Fork 7
feat: restore accepted incoming call from signaling handshake (WT-1167) #1063
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
78b02e2
f51bbc8
f2c6b33
f308dd0
e0b0fbf
d6e3f7a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -30,6 +30,8 @@ import '../extensions/extensions.dart'; | |
| import '../models/models.dart'; | ||
| import '../services/signaling_module.dart'; | ||
| import '../utils/utils.dart'; | ||
| import 'handshake_action.dart'; | ||
| import 'handshake_processor.dart'; | ||
|
|
||
| export 'package:webtrit_callkeep/webtrit_callkeep.dart' show CallkeepHandle, CallkeepHandleType; | ||
|
|
||
|
|
@@ -91,6 +93,7 @@ class CallBloc extends Bloc<CallEvent, CallState> with WidgetsBindingObserver im | |
|
|
||
| late final PeerConnectionManager _peerConnectionManager; | ||
| final Map<String, RenegotiationHandler> _renegotiationHandlers = {}; | ||
| late final HandshakeProcessor _handshakeProcessor; | ||
|
|
||
| final _callkeepSound = WebtritCallkeepSound(); | ||
|
|
||
|
|
@@ -121,6 +124,7 @@ class CallBloc extends Bloc<CallEvent, CallState> with WidgetsBindingObserver im | |
| }) : super(const CallState()) { | ||
| _signalingModule = signalingModule; | ||
| _peerConnectionManager = peerConnectionManager; | ||
| _handshakeProcessor = HandshakeProcessor(callkeepConnections: callkeepConnections); | ||
|
|
||
| _signalingSubscription = _signalingModule.events.listen((event) { | ||
| switch (event) { | ||
|
|
@@ -154,6 +158,7 @@ class CallBloc extends Bloc<CallEvent, CallState> with WidgetsBindingObserver im | |
| on<_HandshakeSignalingEventState>(_onHandshakeSignalingEventState, transformer: sequential()); | ||
| on<_CallSignalingEvent>(_onCallSignalingEvent, transformer: sequential()); | ||
| on<_CallPushEventIncoming>(_onCallPushEventIncoming, transformer: sequential()); | ||
| on<_RestoreAcceptedIncomingCall>(_onRestoreAcceptedIncomingCall, transformer: sequential()); | ||
| on<CallControlEvent>( | ||
| _onCallControlEvent, | ||
| transformer: (events, mapper) => StreamGroup.merge([ | ||
|
|
@@ -2558,66 +2563,195 @@ class CallBloc extends Bloc<CallEvent, CallState> with WidgetsBindingObserver im | |
| ); | ||
| } | ||
|
|
||
| final lines = [...stateHandshake.lines, stateHandshake.guestLine].whereType<Line>(); | ||
| final localConnections = await callkeepConnections.getConnections(); | ||
|
|
||
| for (final activeLine in lines) { | ||
| // Get the first call event from the call logs, if any | ||
| final callEvent = activeLine.callLogs.whereType<CallEventLog>().map((log) => log.callEvent).firstOrNull; | ||
|
|
||
| if (callEvent != null) { | ||
| // Obtain the corresponding Callkeep connection for the line. | ||
| // Callkeep maintains connection states even if the app's lifecycle has ended. | ||
| final connection = await callkeepConnections.getConnection(callEvent.callId); | ||
|
|
||
| // Check if the Callkeep connection exists and its state is `stateDisconnected`. | ||
| // Indicates that the call has been terminated by the user or system (e.g., due to connectivity issues). | ||
| // Synchronize the signaling state with the local state for such scenarios. | ||
| if (connection?.state == CallkeepConnectionState.stateDisconnected) { | ||
| // Handle outgoing or accepted calls. If the event is `AcceptedEvent` or `ProceedingEvent`, | ||
| // initiate a hang-up request to align the signaling state. | ||
| if (callEvent is AcceptedEvent || callEvent is ProceedingEvent) { | ||
| // Handle outgoing or accepted calls. If the event is `AcceptedEvent` or `ProceedingEvent`, | ||
| // initiate a hang-up request to align the signaling state. | ||
| final hangupRequest = HangupRequest( | ||
| transaction: WebtritSignalingClient.generateTransactionId(), | ||
| line: callEvent.line, | ||
| callId: callEvent.callId, | ||
| ); | ||
| await _signalingModule.execute(hangupRequest)?.catchError((e, s) { | ||
| callErrorReporter.handle(e, s, '__onCallPerformEventEnded hangupRequest error'); | ||
| }); | ||
|
|
||
| return; | ||
| } else if (callEvent is IncomingCallEvent) { | ||
| // Handle incoming calls. If the event is `IncomingCallEvent`, send a decline request to update the signaling state accordingly. | ||
| final declineRequest = DeclineRequest( | ||
| transaction: WebtritSignalingClient.generateTransactionId(), | ||
| line: callEvent.line, | ||
| callId: callEvent.callId, | ||
| ); | ||
| await _signalingModule.execute(declineRequest)?.catchError((e, s) { | ||
| callErrorReporter.handle(e, s, '__onCallPerformEventEnded declineRequest error'); | ||
| }); | ||
| return; | ||
| } | ||
| } | ||
| final actions = await _handshakeProcessor.process( | ||
| lines: stateHandshake.lines, | ||
| guestLine: stateHandshake.guestLine, | ||
| activeCallIds: state.activeCalls.map((c) => c.callId).toSet(), | ||
| ); | ||
|
|
||
| for (final action in actions) { | ||
| switch (action) { | ||
| case HangupSignalingAction(): | ||
| await _signalingModule | ||
| .execute( | ||
| HangupRequest( | ||
| transaction: WebtritSignalingClient.generateTransactionId(), | ||
| line: action.line, | ||
| callId: action.callId, | ||
| ), | ||
| ) | ||
| ?.catchError((e, s) => callErrorReporter.handle(e, s, '_handleHandshakeReceived hangupRequest error')); | ||
| return; | ||
|
|
||
| case DeclineSignalingAction(): | ||
| await _signalingModule | ||
| .execute( | ||
| DeclineRequest( | ||
| transaction: WebtritSignalingClient.generateTransactionId(), | ||
| line: action.line, | ||
| callId: action.callId, | ||
| ), | ||
| ) | ||
| ?.catchError((e, s) => callErrorReporter.handle(e, s, '_handleHandshakeReceived declineRequest error')); | ||
| return; | ||
|
|
||
| case RestoreCallAction(): | ||
| _logger.info( | ||
| '_handleHandshakeReceived: accepted incoming call without Callkeep connection — ' | ||
| 'triggering restoration for callId=${action.callId}', | ||
| ); | ||
| add( | ||
| _RestoreAcceptedIncomingCall( | ||
| line: action.line, | ||
| callId: action.callId, | ||
| incomingCallEvent: action.incomingCallEvent, | ||
| acceptedTime: action.acceptedTime, | ||
| ), | ||
| ); | ||
|
|
||
| case HandleIncomingCallAction(): | ||
| _handleSignalingEvent(action.event); | ||
|
|
||
| case EndLocalCallAction(): | ||
| await callkeep.endCall(action.callId); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (activeLine.callLogs.length == 1) { | ||
| final singleCallLog = activeLine.callLogs.first; | ||
| if (singleCallLog is CallEventLog && singleCallLog.callEvent is IncomingCallEvent) { | ||
| _handleSignalingEvent(singleCallLog.callEvent as IncomingCallEvent); | ||
| } | ||
| /// Restores an already-accepted incoming call after Android Activity recreation. | ||
| /// | ||
| /// Triggered by [_handleHandshakeReceived] when the signaling handshake shows a line | ||
| /// with both [IncomingCallEvent] and [AcceptedEvent] in its callLogs, no existing | ||
| /// Callkeep connection, and no entry in [state.activeCalls]. This happens when Android | ||
| /// destroys and recreates the Activity (e.g. a permission change) while a call is active. | ||
| /// | ||
| /// Steps: | ||
| /// 1. Emit an [ActiveCall] in [CallProcessingStatus.incomingRestoringMedia] so the UI | ||
| /// appears immediately. | ||
| /// 2. Re-register the call with Callkeep via [reportNewIncomingCall] + [answerCall] to | ||
| /// restore the native connection in the answered state. | ||
| /// 3. Re-negotiate WebRTC using the original offer SDP from [IncomingCallEvent]. | ||
| /// 4. Send an [AcceptRequest] to signaling with the new local answer — the server | ||
| /// re-establishes the media session. | ||
| /// 5. Transition to [CallProcessingStatus.connected]. | ||
| Future<void> _onRestoreAcceptedIncomingCall(_RestoreAcceptedIncomingCall event, Emitter<CallState> emit) async { | ||
| _logger.info('_onRestoreAcceptedIncomingCall: restoring callId=${event.callId}'); | ||
|
|
||
| final incoming = event.incomingCallEvent; | ||
| final jsep = JsepValue.fromOptional(incoming.jsep); | ||
| final video = jsep?.hasVideo ?? false; | ||
| final handle = CallkeepHandle.number(incoming.caller); | ||
| final contactName = await contactNameResolver.resolveWithNumber(handle.value); | ||
| final displayName = contactName ?? incoming.callerDisplayName; | ||
|
|
||
| // Guard: another event may have already created this call while the contact name resolved. | ||
| if (state.activeCalls.any((c) => c.callId == event.callId)) { | ||
| _logger.info('_onRestoreAcceptedIncomingCall: callId=${event.callId} already in state, skipping'); | ||
| return; | ||
| } | ||
|
|
||
| final activeCall = ActiveCall( | ||
| direction: CallDirection.incoming, | ||
| line: event.line, | ||
| callId: event.callId, | ||
| handle: handle, | ||
| displayName: displayName, | ||
| video: video, | ||
| createdTime: clock.now(), | ||
| incomingOffer: jsep, | ||
| processingStatus: CallProcessingStatus.incomingRestoringMedia, | ||
| ); | ||
| emit(state.copyWithPushActiveCall(activeCall)); | ||
|
|
||
| // Re-register with Callkeep so the native connection is in the answered state. | ||
| final reportError = await callkeep.reportNewIncomingCall( | ||
| event.callId, | ||
| handle, | ||
| displayName: displayName, | ||
| hasVideo: video, | ||
| ); | ||
|
|
||
| final acceptableReportErrors = { | ||
| null, | ||
| CallkeepIncomingCallError.callIdAlreadyExists, | ||
| CallkeepIncomingCallError.callIdAlreadyExistsAndAnswered, | ||
| }; | ||
| if (!acceptableReportErrors.contains(reportError)) { | ||
| _logger.warning('_onRestoreAcceptedIncomingCall: reportNewIncomingCall returned $reportError — aborting'); | ||
| add(_ResetStateEvent.completeCall(event.callId)); | ||
| return; | ||
| } | ||
|
|
||
| if (reportError == null || reportError == CallkeepIncomingCallError.callIdAlreadyExists) { | ||
| final answerError = await callkeep.answerCall(event.callId); | ||
| if (answerError != null) { | ||
| _logger.warning('_onRestoreAcceptedIncomingCall: answerCall error: $answerError'); | ||
| } | ||
| } | ||
|
|
||
| // Synchronize the signaling state with the local state for calls. | ||
| // If a local connection exists that is not present in the signaling state, end the call to ensure consistency between the local and signaling states. | ||
| for (var connection in localConnections) { | ||
| if (!lines.map((e) => e.callId).contains(connection.callId)) { | ||
| await callkeep.endCall(connection.callId); | ||
| MediaStream? localStream; | ||
| RTCPeerConnection? peerConnection; | ||
|
|
||
| try { | ||
| if (jsep == null) { | ||
| throw StateError('_onRestoreAcceptedIncomingCall: no jsep in IncomingCallEvent — cannot restore media'); | ||
| } | ||
|
|
||
| emit( | ||
| state.copyWithMappedActiveCall( | ||
| event.callId, | ||
| (c) => c.copyWith(processingStatus: CallProcessingStatus.incomingInitializingMedia), | ||
| ), | ||
| ); | ||
|
|
||
| localStream = await userMediaBuilder.build(video: jsep.hasVideo, frontCamera: activeCall.frontCamera); | ||
| peerConnection = await _createPeerConnection(event.callId, event.line); | ||
| await Future.forEach(localStream.getTracks(), (t) => peerConnection!.addTrack(t, localStream!)); | ||
|
|
||
| emit( | ||
| state.copyWithMappedActiveCall( | ||
| event.callId, | ||
| (c) => c.copyWith(localStream: localStream, processingStatus: CallProcessingStatus.incomingAnswering), | ||
| ), | ||
| ); | ||
| localStream = null; // ownership transferred to state | ||
|
|
||
| final remoteDescription = jsep.toDescription(); | ||
| sdpSanitizer?.apply(remoteDescription); | ||
| await peerConnection.setRemoteDescription(remoteDescription); | ||
|
|
||
| final localDescription = await peerConnection.createAnswer({}); | ||
| sdpMunger?.apply(localDescription); | ||
|
|
||
| await peerConnection.setLocalDescription(localDescription).catchError((e) => throw SDPConfigurationError(e)); | ||
|
|
||
| await _signalingModule.execute( | ||
| AcceptRequest( | ||
| transaction: WebtritSignalingClient.generateTransactionId(), | ||
| line: event.line, | ||
| callId: event.callId, | ||
| jsep: localDescription.toMap(), | ||
| ), | ||
| ); | ||
|
||
|
|
||
| _peerConnectionManager.complete(event.callId, peerConnection); | ||
| peerConnection = null; // ownership transferred to manager | ||
|
|
||
| emit( | ||
| state.copyWithMappedActiveCall( | ||
| event.callId, | ||
| (c) => c.copyWith(processingStatus: CallProcessingStatus.connected, acceptedTime: event.acceptedTime), | ||
| ), | ||
| ); | ||
|
||
|
|
||
| _logger.info('_onRestoreAcceptedIncomingCall: restoration complete for callId=${event.callId}'); | ||
| } catch (e, stackTrace) { | ||
| localStream?.getTracks().forEach((t) => t.stop()); | ||
| await localStream?.dispose(); | ||
| await peerConnection?.dispose(); | ||
| _peerConnectionManager.completeError(event.callId, e, stackTrace); | ||
| add(_ResetStateEvent.completeCall(event.callId)); | ||
| callErrorReporter.handle(e, stackTrace, '_onRestoreAcceptedIncomingCall error:'); | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| import 'package:webtrit_signaling/webtrit_signaling.dart'; | ||
|
|
||
| /// Actions returned by [HandshakeProcessor.process] describing what the BLoC | ||
| /// should do after processing the signaling [StateHandshake]. | ||
| sealed class HandshakeAction { | ||
| const HandshakeAction(); | ||
| } | ||
|
|
||
| /// Send a [HangupRequest] to the signaling server and stop processing. | ||
| /// | ||
| /// Emitted when the Callkeep connection for the line is [CallkeepConnectionState.stateDisconnected] | ||
| /// and the latest call event is [AcceptedEvent] or [ProceedingEvent]. | ||
| final class HangupSignalingAction extends HandshakeAction { | ||
| const HangupSignalingAction({required this.line, required this.callId}); | ||
|
|
||
| final int? line; | ||
| final String callId; | ||
| } | ||
|
|
||
| /// Send a [DeclineRequest] to the signaling server and stop processing. | ||
| /// | ||
| /// Emitted when the Callkeep connection for the line is [CallkeepConnectionState.stateDisconnected] | ||
| /// and the latest call event is [IncomingCallEvent]. | ||
| final class DeclineSignalingAction extends HandshakeAction { | ||
| const DeclineSignalingAction({required this.line, required this.callId}); | ||
|
|
||
| final int? line; | ||
| final String callId; | ||
| } | ||
|
|
||
| /// Re-negotiate WebRTC media for an already-accepted incoming call (WT-1167 Subtask 2). | ||
| /// | ||
| /// Emitted when the handshake contains both [IncomingCallEvent] (oldest) and [AcceptedEvent] | ||
| /// (newest) for a line, the Callkeep connection is absent, and the call is not already in | ||
| /// the BLoC state. This covers the case of Android Activity recreation during an active call. | ||
| final class RestoreCallAction extends HandshakeAction { | ||
| const RestoreCallAction({ | ||
| required this.line, | ||
| required this.callId, | ||
| required this.incomingCallEvent, | ||
| required this.acceptedTime, | ||
| }); | ||
|
|
||
| final int line; | ||
| final String callId; | ||
| final IncomingCallEvent incomingCallEvent; | ||
| final DateTime acceptedTime; | ||
| } | ||
|
|
||
| /// Deliver an unanswered [IncomingCallEvent] to the BLoC signaling handler. | ||
| /// | ||
| /// Emitted when the line's [callLogs] contains a single [CallEventLog] carrying | ||
| /// an [IncomingCallEvent] — the call has not been answered yet. | ||
| final class HandleIncomingCallAction extends HandshakeAction { | ||
| const HandleIncomingCallAction({required this.event}); | ||
|
|
||
| final IncomingCallEvent event; | ||
| } | ||
|
|
||
| /// Call [Callkeep.endCall] for a local connection that is no longer present in | ||
| /// the signaling state. | ||
| final class EndLocalCallAction extends HandshakeAction { | ||
| const EndLocalCallAction({required this.callId}); | ||
|
|
||
| final String callId; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The state guard that prevents duplicate restoration (
if (state.activeCalls.any(...)) return;) runs after awaitingcontactNameResolver.resolveWithNumber(...). If multiple_RestoreAcceptedIncomingCallevents are queued concurrently for the same callId, both handlers can pass the guard before either emits, leading to duplicate Callkeep/WebRTC operations. Move the guard to the top (before awaits) and/or mark the call as restoring in state immediately to make the handler idempotent under concurrency.