From 78b02e2ce5ee97711f3ec14c080af35a80c81ec8 Mon Sep 17 00:00:00 2001 From: SERDUN Date: Fri, 3 Apr 2026 12:30:28 +0300 Subject: [PATCH 1/6] feat: restore accepted incoming call from signaling handshake (WT-1167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Android recreates the Activity (e.g. permission change) during an active call, the new CallBloc starts fresh. The signaling handshake contains the active call in call_logs (IncomingCallEvent + AcceptedEvent) but _handleHandshakeReceived had no path to restore it — the call was silently lost. Changes: - Add CallProcessingStatus.incomingRestoringMedia for the restoration state - Add _RestoreAcceptedIncomingCall internal event carrying the original IncomingCallEvent from the handshake - In _handleHandshakeReceived: detect the pattern (AcceptedEvent latest + IncomingCallEvent earliest, connection == null, call not in state) and dispatch _RestoreAcceptedIncomingCall; hoist connection variable scope - Add _onRestoreAcceptedIncomingCall handler that: 1. Emits ActiveCall with incomingRestoringMedia status 2. Re-registers with Callkeep via reportNewIncomingCall + answerCall 3. Re-negotiates WebRTC using the original offer SDP 4. Sends AcceptRequest to signaling to re-establish media 5. Transitions to connected --- lib/features/call/bloc/call_bloc.dart | 165 +++++++++++++++++- lib/features/call/bloc/call_event.dart | 13 ++ .../call/models/processing_status.dart | 1 + 3 files changed, 178 insertions(+), 1 deletion(-) diff --git a/lib/features/call/bloc/call_bloc.dart b/lib/features/call/bloc/call_bloc.dart index 66d1bb165..c391b059c 100644 --- a/lib/features/call/bloc/call_bloc.dart +++ b/lib/features/call/bloc/call_bloc.dart @@ -154,6 +154,7 @@ class CallBloc extends Bloc with WidgetsBindingObserver im on<_HandshakeSignalingEventState>(_onHandshakeSignalingEventState, transformer: sequential()); on<_CallSignalingEvent>(_onCallSignalingEvent, transformer: sequential()); on<_CallPushEventIncoming>(_onCallPushEventIncoming, transformer: sequential()); + on<_RestoreAcceptedIncomingCall>(_onRestoreAcceptedIncomingCall, transformer: sequential()); on( _onCallControlEvent, transformer: (events, mapper) => StreamGroup.merge([ @@ -2565,10 +2566,13 @@ class CallBloc extends Bloc with WidgetsBindingObserver im // Get the first call event from the call logs, if any final callEvent = activeLine.callLogs.whereType().map((log) => log.callEvent).firstOrNull; + // Hoisted outside the callEvent block so the restoration detection below can read it. + CallkeepConnection? connection; + 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); + 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). @@ -2604,6 +2608,36 @@ class CallBloc extends Bloc with WidgetsBindingObserver im } } + // WT-1167 Subtask 2: Restore an already-accepted incoming call after Activity recreation. + // + // Detect: callLogs has both IncomingCallEvent (earliest) and AcceptedEvent (latest), + // no Callkeep connection exists (Activity recreation killed it), and the call is not + // already in state (fresh BLoC after recreate). Trigger the restoration flow. + final callEventLogs = activeLine.callLogs.whereType().map((l) => l.callEvent).toList(); + final earliestCallEvent = callEventLogs.firstOrNull; + final latestCallEvent = callEventLogs.lastOrNull; + + final isRestorationCandidate = + earliestCallEvent is IncomingCallEvent && + latestCallEvent is AcceptedEvent && + connection == null && + !state.activeCalls.any((c) => c.callId == activeLine.callId); + + if (isRestorationCandidate) { + _logger.info( + '_handleHandshakeReceived: accepted incoming call without Callkeep connection — ' + 'triggering restoration for callId=${activeLine.callId}', + ); + add( + _RestoreAcceptedIncomingCall( + line: earliestCallEvent.line, + callId: activeLine.callId, + incomingCallEvent: earliestCallEvent, + ), + ); + continue; + } + if (activeLine.callLogs.length == 1) { final singleCallLog = activeLine.callLogs.first; if (singleCallLog is CallEventLog && singleCallLog.callEvent is IncomingCallEvent) { @@ -2621,6 +2655,135 @@ class CallBloc extends Bloc with WidgetsBindingObserver im } } + /// 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 _onRestoreAcceptedIncomingCall(_RestoreAcceptedIncomingCall event, Emitter 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'); + } + } + + 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), + ), + ); + + final localStream = await userMediaBuilder.build(video: jsep.hasVideo, frontCamera: activeCall.frontCamera); + final 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), + ), + ); + + 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); + + emit( + state.copyWithMappedActiveCall( + event.callId, + (c) => c.copyWith(processingStatus: CallProcessingStatus.connected, acceptedTime: clock.now()), + ), + ); + + _logger.info('_onRestoreAcceptedIncomingCall: restoration complete for callId=${event.callId}'); + } catch (e, stackTrace) { + _peerConnectionManager.completeError(event.callId, e, stackTrace); + add(_ResetStateEvent.completeCall(event.callId)); + callErrorReporter.handle(e, stackTrace, '_onRestoreAcceptedIncomingCall error:'); + } + } + void _handleSignalingEvent(Event event) { if (event is IncomingCallEvent) { add( diff --git a/lib/features/call/bloc/call_event.dart b/lib/features/call/bloc/call_event.dart index dae53ebf6..6cb173e10 100644 --- a/lib/features/call/bloc/call_event.dart +++ b/lib/features/call/bloc/call_event.dart @@ -1101,3 +1101,16 @@ class _CallConfigEventUpdated extends CallConfigEvent { @override List get props => [monitorCheckInterval]; } + +// call restoration events + +class _RestoreAcceptedIncomingCall extends CallEvent { + const _RestoreAcceptedIncomingCall({required this.line, required this.callId, required this.incomingCallEvent}); + + final int? line; + final String callId; + final IncomingCallEvent incomingCallEvent; + + @override + List get props => [line, callId, incomingCallEvent]; +} diff --git a/lib/features/call/models/processing_status.dart b/lib/features/call/models/processing_status.dart index 8993636fc..0b6b3407d 100644 --- a/lib/features/call/models/processing_status.dart +++ b/lib/features/call/models/processing_status.dart @@ -5,6 +5,7 @@ enum CallProcessingStatus { incomingPerformingStarted, incomingInitializingMedia, incomingAnswering, + incomingRestoringMedia, outgoingCreated, outgoingCreatedFromRefer, From f51bbc8364fa42c0f0761fa83e39c0f44da00292 Mon Sep 17 00:00:00 2001 From: SERDUN Date: Fri, 3 Apr 2026 13:03:09 +0300 Subject: [PATCH 2/6] fix: apply Copilot review fixes for WT-1167 call restoration - Add `acceptedTime: DateTime` field to `_RestoreAcceptedIncomingCall` event so the original `AcceptedEvent` timestamp is propagated through the flow - Use `event.acceptedTime` instead of `clock.now()` when transitioning to `connected`, so the restored call shows the real accepted time - Fix resource leak in catch block: declare `localStream` and `peerConnection` outside the try, null them out after ownership transfer, and dispose any non-null locals in catch to avoid leaks when an error occurs mid-setup - Add `incomingRestoringMedia` case to l10n extension (was missing, causing a non-exhaustive switch warning) - Tighten `_RestoreAcceptedIncomingCall.line` to non-nullable `int` (null is guarded at the detection site in `_handleHandshakeReceived`) --- lib/features/call/bloc/call_bloc.dart | 29 ++++++++++++++----- lib/features/call/bloc/call_event.dart | 12 ++++++-- .../call/extensions/processing_status.dart | 2 ++ 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/lib/features/call/bloc/call_bloc.dart b/lib/features/call/bloc/call_bloc.dart index c391b059c..4df4207c1 100644 --- a/lib/features/call/bloc/call_bloc.dart +++ b/lib/features/call/bloc/call_bloc.dart @@ -2613,12 +2613,16 @@ class CallBloc extends Bloc with WidgetsBindingObserver im // Detect: callLogs has both IncomingCallEvent (earliest) and AcceptedEvent (latest), // no Callkeep connection exists (Activity recreation killed it), and the call is not // already in state (fresh BLoC after recreate). Trigger the restoration flow. - final callEventLogs = activeLine.callLogs.whereType().map((l) => l.callEvent).toList(); - final earliestCallEvent = callEventLogs.firstOrNull; - final latestCallEvent = callEventLogs.lastOrNull; + final callEventLogEntries = activeLine.callLogs.whereType().toList(); + final earliestCallLog = callEventLogEntries.firstOrNull; + final latestCallLog = callEventLogEntries.lastOrNull; + final earliestCallEvent = earliestCallLog?.callEvent; + final latestCallEvent = latestCallLog?.callEvent; + // Guard: line must be non-null (guest-line calls have line == null and are not restorable). final isRestorationCandidate = earliestCallEvent is IncomingCallEvent && + earliestCallEvent.line != null && latestCallEvent is AcceptedEvent && connection == null && !state.activeCalls.any((c) => c.callId == activeLine.callId); @@ -2630,9 +2634,10 @@ class CallBloc extends Bloc with WidgetsBindingObserver im ); add( _RestoreAcceptedIncomingCall( - line: earliestCallEvent.line, + line: earliestCallEvent.line!, callId: activeLine.callId, incomingCallEvent: earliestCallEvent, + acceptedTime: DateTime.fromMillisecondsSinceEpoch(latestCallLog!.timestamp), ), ); continue; @@ -2726,6 +2731,9 @@ class CallBloc extends Bloc with WidgetsBindingObserver im } } + MediaStream? localStream; + RTCPeerConnection? peerConnection; + try { if (jsep == null) { throw StateError('_onRestoreAcceptedIncomingCall: no jsep in IncomingCallEvent — cannot restore media'); @@ -2738,9 +2746,9 @@ class CallBloc extends Bloc with WidgetsBindingObserver im ), ); - final localStream = await userMediaBuilder.build(video: jsep.hasVideo, frontCamera: activeCall.frontCamera); - final peerConnection = await _createPeerConnection(event.callId, event.line); - await Future.forEach(localStream.getTracks(), (t) => peerConnection.addTrack(t, localStream)); + 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( @@ -2748,6 +2756,7 @@ class CallBloc extends Bloc with WidgetsBindingObserver im (c) => c.copyWith(localStream: localStream, processingStatus: CallProcessingStatus.incomingAnswering), ), ); + localStream = null; // ownership transferred to state final remoteDescription = jsep.toDescription(); sdpSanitizer?.apply(remoteDescription); @@ -2768,16 +2777,20 @@ class CallBloc extends Bloc with WidgetsBindingObserver im ); _peerConnectionManager.complete(event.callId, peerConnection); + peerConnection = null; // ownership transferred to manager emit( state.copyWithMappedActiveCall( event.callId, - (c) => c.copyWith(processingStatus: CallProcessingStatus.connected, acceptedTime: clock.now()), + (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:'); diff --git a/lib/features/call/bloc/call_event.dart b/lib/features/call/bloc/call_event.dart index 6cb173e10..dd564fcf0 100644 --- a/lib/features/call/bloc/call_event.dart +++ b/lib/features/call/bloc/call_event.dart @@ -1105,12 +1105,18 @@ class _CallConfigEventUpdated extends CallConfigEvent { // call restoration events class _RestoreAcceptedIncomingCall extends CallEvent { - const _RestoreAcceptedIncomingCall({required this.line, required this.callId, required this.incomingCallEvent}); + const _RestoreAcceptedIncomingCall({ + required this.line, + required this.callId, + required this.incomingCallEvent, + required this.acceptedTime, + }); - final int? line; + final int line; final String callId; final IncomingCallEvent incomingCallEvent; + final DateTime acceptedTime; @override - List get props => [line, callId, incomingCallEvent]; + List get props => [line, callId, incomingCallEvent, acceptedTime]; } diff --git a/lib/features/call/extensions/processing_status.dart b/lib/features/call/extensions/processing_status.dart index dcd677f97..ff751cc28 100644 --- a/lib/features/call/extensions/processing_status.dart +++ b/lib/features/call/extensions/processing_status.dart @@ -15,6 +15,8 @@ extension ProcessingStatusL10n on CallProcessingStatus { return context.l10n.callProcessingStatus_init_media; case CallProcessingStatus.incomingAnswering: return context.l10n.callProcessingStatus_answering; + case CallProcessingStatus.incomingRestoringMedia: + return context.l10n.callProcessingStatus_init_media; case CallProcessingStatus.outgoingCreated || CallProcessingStatus.outgoingCreatedFromRefer: return context.l10n.callProcessingStatus_preparing; From f2c6b33db0e92dd2680737d3dcb95fabf8666db7 Mon Sep 17 00:00:00 2001 From: SERDUN Date: Fri, 3 Apr 2026 13:58:26 +0300 Subject: [PATCH 3/6] fix: correct firstOrNull/lastOrNull swap in handshake restoration detection callLogs is ordered newest-first, so: - firstOrNull = AcceptedEvent (latest) - lastOrNull = IncomingCallEvent (earliest / original offer) The previous code assigned them to variables named `earliest` and `latest` in the wrong order, making the `isRestorationCandidate` type checks always evaluate to false and the restoration path never triggered. --- lib/features/call/bloc/call_bloc.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/features/call/bloc/call_bloc.dart b/lib/features/call/bloc/call_bloc.dart index 4df4207c1..30216c738 100644 --- a/lib/features/call/bloc/call_bloc.dart +++ b/lib/features/call/bloc/call_bloc.dart @@ -2613,11 +2613,12 @@ class CallBloc extends Bloc with WidgetsBindingObserver im // Detect: callLogs has both IncomingCallEvent (earliest) and AcceptedEvent (latest), // no Callkeep connection exists (Activity recreation killed it), and the call is not // already in state (fresh BLoC after recreate). Trigger the restoration flow. + // callLogs is newest-first: firstOrNull = AcceptedEvent (latest), lastOrNull = IncomingCallEvent (earliest). final callEventLogEntries = activeLine.callLogs.whereType().toList(); - final earliestCallLog = callEventLogEntries.firstOrNull; - final latestCallLog = callEventLogEntries.lastOrNull; - final earliestCallEvent = earliestCallLog?.callEvent; + final latestCallLog = callEventLogEntries.firstOrNull; + final earliestCallLog = callEventLogEntries.lastOrNull; final latestCallEvent = latestCallLog?.callEvent; + final earliestCallEvent = earliestCallLog?.callEvent; // Guard: line must be non-null (guest-line calls have line == null and are not restorable). final isRestorationCandidate = From f308dd07789d9c6fc0a0acd48db098b6ac3e5536 Mon Sep 17 00:00:00 2001 From: SERDUN Date: Fri, 3 Apr 2026 14:52:10 +0300 Subject: [PATCH 4/6] refactor: extract HandshakeProcessor from _handleHandshakeReceived (WT-1167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the per-line and local-connection decision logic from CallBloc into a dedicated HandshakeProcessor class that returns a sealed HandshakeAction list. CallBloc._handleHandshakeReceived now only executes those actions (signaling calls, callkeep calls, BLoC event dispatch), keeping all decision logic testable without platform dependencies. New files: - handshake_action.dart — sealed action hierarchy (Hangup, Decline, Restore, HandleIncoming, EndLocalCall) - handshake_processor.dart — stateless processor; only depends on CallkeepConnections (mockable) - test/…/handshake_processor_test.dart — 16 unit tests covering every branch, including the callLogs newest-first ordering that caused the WT-1167 swap-bug --- lib/features/call/bloc/call_bloc.dart | 147 ++++----- lib/features/call/bloc/handshake_action.dart | 66 ++++ .../call/bloc/handshake_processor.dart | 108 +++++++ .../call/bloc/handshake_processor_test.dart | 293 ++++++++++++++++++ 4 files changed, 519 insertions(+), 95 deletions(-) create mode 100644 lib/features/call/bloc/handshake_action.dart create mode 100644 lib/features/call/bloc/handshake_processor.dart create mode 100644 test/features/call/bloc/handshake_processor_test.dart diff --git a/lib/features/call/bloc/call_bloc.dart b/lib/features/call/bloc/call_bloc.dart index 30216c738..dd7d1b8e6 100644 --- a/lib/features/call/bloc/call_bloc.dart +++ b/lib/features/call/bloc/call_bloc.dart @@ -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 with WidgetsBindingObserver im late final PeerConnectionManager _peerConnectionManager; final Map _renegotiationHandlers = {}; + late final HandshakeProcessor _handshakeProcessor; final _callkeepSound = WebtritCallkeepSound(); @@ -121,6 +124,7 @@ class CallBloc extends Bloc with WidgetsBindingObserver im }) : super(const CallState()) { _signalingModule = signalingModule; _peerConnectionManager = peerConnectionManager; + _handshakeProcessor = HandshakeProcessor(callkeepConnections: callkeepConnections); _signalingSubscription = _signalingModule.events.listen((event) { switch (event) { @@ -2559,104 +2563,57 @@ class CallBloc extends Bloc with WidgetsBindingObserver im ); } - final lines = [...stateHandshake.lines, stateHandshake.guestLine].whereType(); - 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().map((log) => log.callEvent).firstOrNull; - - // Hoisted outside the callEvent block so the restoration detection below can read it. - CallkeepConnection? connection; - - if (callEvent != null) { - // Obtain the corresponding Callkeep connection for the line. - // Callkeep maintains connection states even if the app's lifecycle has ended. - 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(), + ); - // WT-1167 Subtask 2: Restore an already-accepted incoming call after Activity recreation. - // - // Detect: callLogs has both IncomingCallEvent (earliest) and AcceptedEvent (latest), - // no Callkeep connection exists (Activity recreation killed it), and the call is not - // already in state (fresh BLoC after recreate). Trigger the restoration flow. - // callLogs is newest-first: firstOrNull = AcceptedEvent (latest), lastOrNull = IncomingCallEvent (earliest). - final callEventLogEntries = activeLine.callLogs.whereType().toList(); - final latestCallLog = callEventLogEntries.firstOrNull; - final earliestCallLog = callEventLogEntries.lastOrNull; - final latestCallEvent = latestCallLog?.callEvent; - final earliestCallEvent = earliestCallLog?.callEvent; - - // Guard: line must be non-null (guest-line calls have line == null and are not restorable). - final isRestorationCandidate = - earliestCallEvent is IncomingCallEvent && - earliestCallEvent.line != null && - latestCallEvent is AcceptedEvent && - connection == null && - !state.activeCalls.any((c) => c.callId == activeLine.callId); - - if (isRestorationCandidate) { - _logger.info( - '_handleHandshakeReceived: accepted incoming call without Callkeep connection — ' - 'triggering restoration for callId=${activeLine.callId}', - ); - add( - _RestoreAcceptedIncomingCall( - line: earliestCallEvent.line!, - callId: activeLine.callId, - incomingCallEvent: earliestCallEvent, - acceptedTime: DateTime.fromMillisecondsSinceEpoch(latestCallLog!.timestamp), - ), - ); - continue; - } + 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, + ), + ); - if (activeLine.callLogs.length == 1) { - final singleCallLog = activeLine.callLogs.first; - if (singleCallLog is CallEventLog && singleCallLog.callEvent is IncomingCallEvent) { - _handleSignalingEvent(singleCallLog.callEvent as IncomingCallEvent); - } - } - } + case HandleIncomingCallAction(): + _handleSignalingEvent(action.event); - // 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); + case EndLocalCallAction(): + await callkeep.endCall(action.callId); } } } diff --git a/lib/features/call/bloc/handshake_action.dart b/lib/features/call/bloc/handshake_action.dart new file mode 100644 index 000000000..1960a9d1e --- /dev/null +++ b/lib/features/call/bloc/handshake_action.dart @@ -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; +} diff --git a/lib/features/call/bloc/handshake_processor.dart b/lib/features/call/bloc/handshake_processor.dart new file mode 100644 index 000000000..252be1775 --- /dev/null +++ b/lib/features/call/bloc/handshake_processor.dart @@ -0,0 +1,108 @@ +import 'package:webtrit_callkeep/webtrit_callkeep.dart'; +import 'package:webtrit_signaling/webtrit_signaling.dart'; + +import 'handshake_action.dart'; + +/// Processes a [StateHandshake] and returns the list of [HandshakeAction]s the +/// BLoC should execute. +/// +/// Separating the decision logic from execution (signaling calls, callkeep calls, +/// BLoC event dispatch) keeps this class free of side effects and makes it +/// straightforward to unit-test with only a mocked [CallkeepConnections]. +/// +/// The processor handles two loops from the original [CallBloc._handleHandshakeReceived]: +/// +/// **Loop B — per-line decisions:** +/// - If the Callkeep connection is [CallkeepConnectionState.stateDisconnected] and +/// the latest event is [AcceptedEvent]/[ProceedingEvent] → [HangupSignalingAction]. +/// - If the Callkeep connection is [CallkeepConnectionState.stateDisconnected] and +/// the latest event is [IncomingCallEvent] → [DeclineSignalingAction]. +/// - If the call was accepted ([AcceptedEvent] newest, [IncomingCallEvent] oldest) +/// with no local connection → [RestoreCallAction]. +/// - If only a single unanswered [IncomingCallEvent] is present → [HandleIncomingCallAction]. +/// +/// **Loop C — orphaned local connections:** +/// - For each local Callkeep connection whose call ID is absent from the handshake +/// lines → [EndLocalCallAction]. +/// +/// [HangupSignalingAction] and [DeclineSignalingAction] are always returned as the +/// sole action — the processor exits early to match the original `return` semantics. +class HandshakeProcessor { + HandshakeProcessor({required this.callkeepConnections}); + + final CallkeepConnections callkeepConnections; + + Future> process({ + required List lines, + required Line? guestLine, + required Set activeCallIds, + }) async { + final actions = []; + final allLines = [...lines, guestLine].whereType().toList(); + final localConnections = await callkeepConnections.getConnections(); + + for (final activeLine in allLines) { + // Get the newest call event from the call logs, if any. + final callEvent = activeLine.callLogs.whereType().map((log) => log.callEvent).firstOrNull; + + CallkeepConnection? connection; + if (callEvent != null) { + connection = await callkeepConnections.getConnection(callEvent.callId); + + if (connection?.state == CallkeepConnectionState.stateDisconnected) { + if (callEvent is AcceptedEvent || callEvent is ProceedingEvent) { + // Early exit: only this action, consistent with the original `return` in the BLoC. + return [HangupSignalingAction(line: callEvent.line, callId: callEvent.callId)]; + } else if (callEvent is IncomingCallEvent) { + // Early exit: only this action. + return [DeclineSignalingAction(line: callEvent.line, callId: callEvent.callId)]; + } + } + } + + // WT-1167 Subtask 2: restore an accepted incoming call after Activity recreation. + // + // callLogs is newest-first: + // firstOrNull → AcceptedEvent (latest) + // lastOrNull → IncomingCallEvent (earliest / original offer) + final callEventLogEntries = activeLine.callLogs.whereType().toList(); + final latestCallLog = callEventLogEntries.firstOrNull; + final earliestCallLog = callEventLogEntries.lastOrNull; + final latestCallEvent = latestCallLog?.callEvent; + final earliestCallEvent = earliestCallLog?.callEvent; + + if (earliestCallEvent is IncomingCallEvent && + earliestCallEvent.line != null && + latestCallEvent is AcceptedEvent && + connection == null && + !activeCallIds.contains(activeLine.callId)) { + actions.add( + RestoreCallAction( + line: earliestCallEvent.line!, + callId: activeLine.callId, + incomingCallEvent: earliestCallEvent, + acceptedTime: DateTime.fromMillisecondsSinceEpoch(latestCallLog!.timestamp), + ), + ); + continue; + } + + if (activeLine.callLogs.length == 1) { + final singleCallLog = activeLine.callLogs.first; + if (singleCallLog is CallEventLog && singleCallLog.callEvent is IncomingCallEvent) { + actions.add(HandleIncomingCallAction(event: singleCallLog.callEvent as IncomingCallEvent)); + } + } + } + + // Synchronize local connections: end any that are absent from the signaling state. + final lineCallIds = allLines.map((l) => l.callId).toSet(); + for (final connection in localConnections) { + if (!lineCallIds.contains(connection.callId)) { + actions.add(EndLocalCallAction(callId: connection.callId)); + } + } + + return actions; + } +} diff --git a/test/features/call/bloc/handshake_processor_test.dart b/test/features/call/bloc/handshake_processor_test.dart new file mode 100644 index 000000000..d636931d7 --- /dev/null +++ b/test/features/call/bloc/handshake_processor_test.dart @@ -0,0 +1,293 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:webtrit_callkeep/webtrit_callkeep.dart'; +import 'package:webtrit_signaling/webtrit_signaling.dart'; + +import 'package:webtrit_phone/features/call/bloc/handshake_action.dart'; +import 'package:webtrit_phone/features/call/bloc/handshake_processor.dart'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +class MockCallkeepConnections extends Mock implements CallkeepConnections {} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const _kCallId = 'call-abc'; +const _kLine = 0; + +IncomingCallEvent _makeIncomingEvent({int line = _kLine, String callId = _kCallId}) { + return IncomingCallEvent(line: line, callId: callId, callee: 'callee', caller: '1234'); +} + +AcceptedEvent _makeAcceptedEvent({int? line = _kLine, String callId = _kCallId}) { + return AcceptedEvent(line: line, callId: callId); +} + +ProceedingEvent _makeProceedingEvent({int? line = _kLine, String callId = _kCallId}) { + return ProceedingEvent(line: line, callId: callId, code: 180); +} + +Line _makeLine({String callId = _kCallId, required List callLogs}) { + return Line(callId: callId, callLogs: callLogs); +} + +CallkeepConnection _makeConnection({ + String callId = _kCallId, + CallkeepConnectionState state = CallkeepConnectionState.stateActive, +}) { + return CallkeepConnection(callId: callId, state: state, disconnectCause: null); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + late MockCallkeepConnections mockConnections; + late HandshakeProcessor processor; + + setUp(() { + mockConnections = MockCallkeepConnections(); + processor = HandshakeProcessor(callkeepConnections: mockConnections); + + // Default: no local connections, no connection for any callId. + when(() => mockConnections.getConnections()).thenAnswer((_) async => []); + when(() => mockConnections.getConnection(any())).thenAnswer((_) async => null); + }); + + // ------------------------------------------------------------------------- + // Empty handshake + // ------------------------------------------------------------------------- + + group('empty handshake', () { + test('returns empty list when lines is empty', () async { + final actions = await processor.process(lines: [], guestLine: null, activeCallIds: {}); + expect(actions, isEmpty); + }); + + test('returns empty list when all lines are null', () async { + final actions = await processor.process(lines: [null, null], guestLine: null, activeCallIds: {}); + expect(actions, isEmpty); + }); + }); + + // ------------------------------------------------------------------------- + // Unanswered incoming call (single CallEventLog) + // ------------------------------------------------------------------------- + + group('single unanswered IncomingCallEvent', () { + test('returns HandleIncomingCallAction', () async { + final line = _makeLine(callLogs: [CallEventLog(timestamp: 1000, callEvent: _makeIncomingEvent())]); + + final actions = await processor.process(lines: [line], guestLine: null, activeCallIds: {}); + + expect(actions, hasLength(1)); + expect(actions.first, isA()); + final a = actions.first as HandleIncomingCallAction; + expect(a.event.callId, _kCallId); + }); + }); + + // ------------------------------------------------------------------------- + // Restoration: AcceptedEvent (newest) + IncomingCallEvent (oldest) + // ------------------------------------------------------------------------- + + group('restoration candidate', () { + Line _makeRestorationLine() => _makeLine( + callLogs: [ + CallEventLog(timestamp: 2000, callEvent: _makeAcceptedEvent()), + CallEventLog(timestamp: 1000, callEvent: _makeIncomingEvent()), + ], + ); + + test('returns RestoreCallAction when connection is null and call not in state', () async { + final line = _makeRestorationLine(); + final actions = await processor.process(lines: [line], guestLine: null, activeCallIds: {}); + + expect(actions, hasLength(1)); + expect(actions.first, isA()); + final a = actions.first as RestoreCallAction; + expect(a.callId, _kCallId); + expect(a.line, _kLine); + expect(a.acceptedTime, DateTime.fromMillisecondsSinceEpoch(2000)); + }); + + test('uses AcceptedEvent timestamp (newest) as acceptedTime', () async { + final line = _makeLine( + callLogs: [ + CallEventLog(timestamp: 9999, callEvent: _makeAcceptedEvent()), + CallEventLog(timestamp: 1111, callEvent: _makeIncomingEvent()), + ], + ); + + final actions = await processor.process(lines: [line], guestLine: null, activeCallIds: {}); + + final a = actions.first as RestoreCallAction; + expect(a.acceptedTime, DateTime.fromMillisecondsSinceEpoch(9999)); + }); + + test('skips restoration when callId already in activeCallIds', () async { + final line = _makeRestorationLine(); + final actions = await processor.process(lines: [line], guestLine: null, activeCallIds: {_kCallId}); + + expect(actions, isEmpty); + }); + + test('skips restoration when connection is not null', () async { + when(() => mockConnections.getConnection(_kCallId)).thenAnswer((_) async => _makeConnection()); + + final line = _makeRestorationLine(); + final actions = await processor.process(lines: [line], guestLine: null, activeCallIds: {}); + + expect(actions, isEmpty); + }); + + test('skips restoration when IncomingCallEvent.line is null (guest-line call)', () async { + // IncomingCallEvent with line == null — not restorable. + final incomingWithNullLine = IncomingCallEvent(line: null, callId: _kCallId, callee: 'callee', caller: '1234'); + final lineWithNullLine = _makeLine( + callLogs: [ + CallEventLog(timestamp: 2000, callEvent: _makeAcceptedEvent()), + CallEventLog(timestamp: 1000, callEvent: incomingWithNullLine), + ], + ); + + final actions = await processor.process(lines: [lineWithNullLine], guestLine: null, activeCallIds: {}); + + expect(actions, isEmpty); + }); + + test('this specific order (newest=AcceptedEvent, oldest=IncomingCallEvent) is required', () async { + // Swapped order: oldest=AcceptedEvent, newest=IncomingCallEvent — must NOT trigger restore. + final lineSwapped = _makeLine( + callLogs: [ + CallEventLog(timestamp: 2000, callEvent: _makeIncomingEvent()), // newest = IncomingCallEvent + CallEventLog(timestamp: 1000, callEvent: _makeAcceptedEvent()), // oldest = AcceptedEvent + ], + ); + + final actions = await processor.process(lines: [lineSwapped], guestLine: null, activeCallIds: {}); + + // Should produce HandleIncomingCallAction only if single log, otherwise nothing. + // With 2 logs none of the conditions match. + expect(actions.whereType(), isEmpty); + }); + }); + + // ------------------------------------------------------------------------- + // stateDisconnected connection — HangupSignalingAction + // ------------------------------------------------------------------------- + + group('stateDisconnected with AcceptedEvent', () { + test('returns only HangupSignalingAction (early exit)', () async { + when( + () => mockConnections.getConnection(_kCallId), + ).thenAnswer((_) async => _makeConnection(state: CallkeepConnectionState.stateDisconnected)); + + final line = _makeLine(callLogs: [CallEventLog(timestamp: 1000, callEvent: _makeAcceptedEvent())]); + + final actions = await processor.process(lines: [line], guestLine: null, activeCallIds: {}); + + expect(actions, hasLength(1)); + expect(actions.first, isA()); + final a = actions.first as HangupSignalingAction; + expect(a.callId, _kCallId); + expect(a.line, _kLine); + }); + + test('returns only HangupSignalingAction for ProceedingEvent', () async { + when( + () => mockConnections.getConnection(_kCallId), + ).thenAnswer((_) async => _makeConnection(state: CallkeepConnectionState.stateDisconnected)); + + final line = _makeLine(callLogs: [CallEventLog(timestamp: 1000, callEvent: _makeProceedingEvent())]); + + final actions = await processor.process(lines: [line], guestLine: null, activeCallIds: {}); + + expect(actions, hasLength(1)); + expect(actions.first, isA()); + }); + + test('early exit: EndLocalCallAction is NOT generated even if orphaned connections exist', () async { + when( + () => mockConnections.getConnection(_kCallId), + ).thenAnswer((_) async => _makeConnection(state: CallkeepConnectionState.stateDisconnected)); + when(() => mockConnections.getConnections()).thenAnswer((_) async => [_makeConnection(callId: 'orphan-id')]); + + final line = _makeLine(callLogs: [CallEventLog(timestamp: 1000, callEvent: _makeAcceptedEvent())]); + + final actions = await processor.process(lines: [line], guestLine: null, activeCallIds: {}); + + expect(actions, hasLength(1)); + expect(actions.whereType(), isEmpty); + }); + }); + + // ------------------------------------------------------------------------- + // stateDisconnected connection — DeclineSignalingAction + // ------------------------------------------------------------------------- + + group('stateDisconnected with IncomingCallEvent', () { + test('returns only DeclineSignalingAction (early exit)', () async { + when( + () => mockConnections.getConnection(_kCallId), + ).thenAnswer((_) async => _makeConnection(state: CallkeepConnectionState.stateDisconnected)); + + final line = _makeLine(callLogs: [CallEventLog(timestamp: 1000, callEvent: _makeIncomingEvent())]); + + final actions = await processor.process(lines: [line], guestLine: null, activeCallIds: {}); + + expect(actions, hasLength(1)); + expect(actions.first, isA()); + final a = actions.first as DeclineSignalingAction; + expect(a.callId, _kCallId); + }); + }); + + // ------------------------------------------------------------------------- + // Orphaned local connections — EndLocalCallAction + // ------------------------------------------------------------------------- + + group('local connection not in handshake', () { + test('returns EndLocalCallAction for each orphaned local connection', () async { + when( + () => mockConnections.getConnections(), + ).thenAnswer((_) async => [_makeConnection(callId: 'orphan-1'), _makeConnection(callId: 'orphan-2')]); + + final actions = await processor.process(lines: [], guestLine: null, activeCallIds: {}); + + expect(actions, hasLength(2)); + expect(actions.every((a) => a is EndLocalCallAction), isTrue); + final ids = actions.cast().map((a) => a.callId).toSet(); + expect(ids, {'orphan-1', 'orphan-2'}); + }); + + test('does NOT return EndLocalCallAction when local connection callId is in handshake', () async { + when(() => mockConnections.getConnections()).thenAnswer((_) async => [_makeConnection(callId: _kCallId)]); + + final line = _makeLine(callLogs: []); + final actions = await processor.process(lines: [line], guestLine: null, activeCallIds: {}); + + expect(actions.whereType(), isEmpty); + }); + }); + + // ------------------------------------------------------------------------- + // guestLine is treated like a regular line + // ------------------------------------------------------------------------- + + group('guestLine', () { + test('processes guestLine the same as regular lines', () async { + final guestLine = _makeLine(callLogs: [CallEventLog(timestamp: 1000, callEvent: _makeIncomingEvent())]); + + final actions = await processor.process(lines: [], guestLine: guestLine, activeCallIds: {}); + + expect(actions, hasLength(1)); + expect(actions.first, isA()); + }); + }); +} From e0b0fbf4d5bde71e53ca853b3e458a06fd85ee2e Mon Sep 17 00:00:00 2001 From: SERDUN Date: Fri, 3 Apr 2026 15:25:40 +0300 Subject: [PATCH 5/6] fix: use UpdateRequest instead of AcceptRequest on call restoration (WT-1167) --- lib/features/call/bloc/call_bloc.dart | 63 +++++++++------------------ 1 file changed, 21 insertions(+), 42 deletions(-) diff --git a/lib/features/call/bloc/call_bloc.dart b/lib/features/call/bloc/call_bloc.dart index dd7d1b8e6..626131e61 100644 --- a/lib/features/call/bloc/call_bloc.dart +++ b/lib/features/call/bloc/call_bloc.dart @@ -2630,10 +2630,15 @@ class CallBloc extends Bloc with WidgetsBindingObserver im /// 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]. + /// 3. Acquire user media, create a new [RTCPeerConnection], and add local tracks. + /// 4. Transition immediately to [CallProcessingStatus.connected] with the original + /// [acceptedTime] — the server already has this call in [status=incall], so + /// [AcceptRequest] would be rejected (ERROR 447 "Wrong state"). + /// 5. Complete the PC into [_peerConnectionManager], which triggers [onRenegotiationNeeded] + /// → [_safeRenegotiate] → [UpdateRequest] (re-INVITE) to re-establish the media path. + /// 6. The server responds with [AcceptedEvent] carrying an answer SDP; + /// [__onCallSignalingEventAccepted] applies [setRemoteDescription] and ICE negotiation + /// resumes, restoring audio/video. Future _onRestoreAcceptedIncomingCall(_RestoreAcceptedIncomingCall event, Emitter emit) async { _logger.info('_onRestoreAcceptedIncomingCall: restoring callId=${event.callId}'); @@ -2693,57 +2698,31 @@ class CallBloc extends Bloc with WidgetsBindingObserver im 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); + localStream = await userMediaBuilder.build(video: video, frontCamera: activeCall.frontCamera); peerConnection = await _createPeerConnection(event.callId, event.line); await Future.forEach(localStream.getTracks(), (t) => peerConnection!.addTrack(t, localStream!)); + // The server already has this call in status=incall, so AcceptRequest would be rejected + // (ERROR 447 "Wrong state"). Mark the call as connected with the original acceptedTime so + // that when the server responds to the UpdateRequest below, __onCallSignalingEventAccepted + // treats it as an update (acceptedTime != null) and applies setRemoteDescription correctly. emit( state.copyWithMappedActiveCall( event.callId, - (c) => c.copyWith(localStream: localStream, processingStatus: CallProcessingStatus.incomingAnswering), + (c) => c.copyWith( + localStream: localStream, + processingStatus: CallProcessingStatus.connected, + acceptedTime: event.acceptedTime, + ), ), ); 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(), - ), - ); - + // Completing the PC triggers onRenegotiationNeeded → _safeRenegotiate → UpdateRequest + // (re-INVITE), which re-establishes media after Activity recreation. _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()); From d6e3f7a3c1f27102af852fd1267c07f780d408aa Mon Sep 17 00:00:00 2001 From: SERDUN Date: Fri, 3 Apr 2026 15:37:41 +0300 Subject: [PATCH 6/6] fix: explicitly dispatch renegotiation after restoration to bypass null signalingState guard (WT-1167) --- lib/features/call/bloc/call_bloc.dart | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/features/call/bloc/call_bloc.dart b/lib/features/call/bloc/call_bloc.dart index 626131e61..bb64e045c 100644 --- a/lib/features/call/bloc/call_bloc.dart +++ b/lib/features/call/bloc/call_bloc.dart @@ -2634,8 +2634,11 @@ class CallBloc extends Bloc with WidgetsBindingObserver im /// 4. Transition immediately to [CallProcessingStatus.connected] with the original /// [acceptedTime] — the server already has this call in [status=incall], so /// [AcceptRequest] would be rejected (ERROR 447 "Wrong state"). - /// 5. Complete the PC into [_peerConnectionManager], which triggers [onRenegotiationNeeded] - /// → [_safeRenegotiate] → [UpdateRequest] (re-INVITE) to re-establish the media path. + /// 5. Complete the PC into [_peerConnectionManager] and dispatch + /// [_PeerConnectionEventRenegotiationNeeded] explicitly — [onRenegotiationNeeded] + /// fires during initial setup when [RTCPeerConnection.signalingState] is still null + /// and is suppressed by the guard in [_createPeerConnection]; dispatching the event + /// directly ensures [_safeRenegotiate] → [UpdateRequest] (re-INVITE) always runs. /// 6. The server responds with [AcceptedEvent] carrying an answer SDP; /// [__onCallSignalingEventAccepted] applies [setRemoteDescription] and ICE negotiation /// resumes, restoring audio/video. @@ -2718,11 +2721,15 @@ class CallBloc extends Bloc with WidgetsBindingObserver im ); localStream = null; // ownership transferred to state - // Completing the PC triggers onRenegotiationNeeded → _safeRenegotiate → UpdateRequest - // (re-INVITE), which re-establishes media after Activity recreation. _peerConnectionManager.complete(event.callId, peerConnection); peerConnection = null; // ownership transferred to manager + // onRenegotiationNeeded fires during initial PC setup (addTrack) when + // signalingState is still null, so its guard skips the event. Dispatch + // it explicitly here so _safeRenegotiate sends UpdateRequest (re-INVITE) + // to re-establish media with the server that is already in status=incall. + add(_PeerConnectionEvent.renegotiationNeeded(event.callId, event.line)); + _logger.info('_onRestoreAcceptedIncomingCall: restoration complete for callId=${event.callId}'); } catch (e, stackTrace) { localStream?.getTracks().forEach((t) => t.stop());