diff --git a/lib/features/call/bloc/call_bloc.dart b/lib/features/call/bloc/call_bloc.dart index 66d1bb165..bb64e045c 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) { @@ -154,6 +158,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([ @@ -2558,67 +2563,182 @@ 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; - - 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(), + ); - if (activeLine.callLogs.length == 1) { - final singleCallLog = activeLine.callLogs.first; - if (singleCallLog is CallEventLog && singleCallLog.callEvent is IncomingCallEvent) { - _handleSignalingEvent(singleCallLog.callEvent as IncomingCallEvent); - } + 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); } } + } + + /// 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. 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] 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. + 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; + } - // 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); + if (reportError == null || reportError == CallkeepIncomingCallError.callIdAlreadyExists) { + final answerError = await callkeep.answerCall(event.callId); + if (answerError != null) { + _logger.warning('_onRestoreAcceptedIncomingCall: answerCall error: $answerError'); } } + + MediaStream? localStream; + RTCPeerConnection? peerConnection; + + try { + 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.connected, + acceptedTime: event.acceptedTime, + ), + ), + ); + localStream = null; // ownership transferred to state + + _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()); + await localStream?.dispose(); + await peerConnection?.dispose(); + _peerConnectionManager.completeError(event.callId, e, stackTrace); + add(_ResetStateEvent.completeCall(event.callId)); + callErrorReporter.handle(e, stackTrace, '_onRestoreAcceptedIncomingCall error:'); + } } void _handleSignalingEvent(Event event) { diff --git a/lib/features/call/bloc/call_event.dart b/lib/features/call/bloc/call_event.dart index dae53ebf6..dd564fcf0 100644 --- a/lib/features/call/bloc/call_event.dart +++ b/lib/features/call/bloc/call_event.dart @@ -1101,3 +1101,22 @@ 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, + required this.acceptedTime, + }); + + final int line; + final String callId; + final IncomingCallEvent incomingCallEvent; + final DateTime acceptedTime; + + @override + List get props => [line, callId, incomingCallEvent, acceptedTime]; +} 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/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; 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, 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()); + }); + }); +}