Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
242 changes: 188 additions & 54 deletions lib/features/call/bloc/call_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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);

Copilot AI Apr 3, 2026

Copy link

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 awaiting contactNameResolver.resolveWithNumber(...). If multiple _RestoreAcceptedIncomingCall events 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.

Copilot uses AI. Check for mistakes.
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(),
),
);

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_signalingModule.execute(...) is nullable (returns null when not connected). Here the AcceptRequest send is awaited without checking for null, so if signaling is disconnected the await becomes a no-op and the code will still complete() the peer connection and transition the call to connected, leaving app state inconsistent with server signaling. Treat a null execute as an error (or reconnect and retry) and only transition to connected after confirming the request was actually dispatched / acknowledged.

Copilot uses AI. Check for mistakes.

_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),
),
);

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In _onRestoreAcceptedIncomingCall, _signalingModule.execute(...) returns Future<void>? (null when disconnected). If it returns null here, the code will still proceed to _peerConnectionManager.complete(...) and emit CallProcessingStatus.connected, even though the AcceptRequest was never sent. Consider treating a null return as a hard failure (e.g., throw/abort restoration + reset state) so we don't end up with a locally “connected” call that the server never accepted.

Copilot uses AI. Check for mistakes.

_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:');
}
}

Expand Down
19 changes: 19 additions & 0 deletions lib/features/call/bloc/call_event.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1101,3 +1101,22 @@ class _CallConfigEventUpdated extends CallConfigEvent {
@override
List<Object?> 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<Object?> get props => [line, callId, incomingCallEvent, acceptedTime];
}
66 changes: 66 additions & 0 deletions lib/features/call/bloc/handshake_action.dart
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;
}
Loading
Loading