answer method
Answer the current inbound call — join as a WebRTC leg.
Implementation
Future<void> answer([String? sessionId]) async {
var call = current.value;
// The other side ended it just as we answered — don't join a dead session
// (that hangs on "connecting"); make sure the native UI is gone and bail.
final targetSid = sessionId ?? call?.sessionId;
if (targetSid != null && _recentlyEnded.contains(targetSid)) {
NexusLog.info('voice: answer ignored — $targetSid already ended by remote');
_dismissNativeCall(targetSid);
return;
}
// Cold-launch accept: the app was killed and the user accepted from the
// native UI — reconstruct the inbound call from the session id in the event.
if (call == null && sessionId != null) {
call = NexusCall(
sessionId: sessionId,
direction: VoiceCallDirection.inbound,
state: VoiceCallState.connecting,
startedAt: DateTime.now(),
);
_set(call);
}
if (call == null || call.direction != VoiceCallDirection.inbound) return;
// The accept can reach us via BOTH the CallKit event stream and the
// cold-launch poll — answer each call exactly once, or we create two legs.
if (_answeringSessionId == call.sessionId) return;
_answeringSessionId = call.sessionId;
NexusLog.info('voice: answering call ${call.sessionId}');
// Do NOT prompt here: answering happens from the system call UI, often in a
// background launch where the dialog cannot appear and the answer is an
// instant meaningless refusal.
if (!await _ensureMicPermission(allowPrompt: false)) {
if (!kIsWeb && Platform.isIOS) {
// iOS: the user just accepted this call on the CallKit screen. Failing it
// over a permission we were never able to ask for would drop a live call,
// so join anyway — LiveKit opens the mic the moment access is granted,
// and the call is audible in the meantime in the other direction.
NexusLog.error('voice: answering WITHOUT confirmed microphone access — the caller '
'may not hear this device. Call Nexus.instance.voice.ensurePermissions() '
'during onboarding so the prompt happens while the app is open.');
} else {
_fail(call.sessionId, 'microphone_denied');
return;
}
}
try {
_set(call.copyWith(state: VoiceCallState.connecting));
final myLeg = await _post('/partner/voice/legs', {
'sessionId': call.sessionId,
'role': 'callee',
'endpointType': 'webrtc',
'direction': 'inbound',
'identityId': _identityId, // tag the media leg with WHO answered
});
final legId = _id(myLeg?['leg']);
final token = NexusJoinToken.tryParse(myLeg?['join'] as Map<String, dynamic>?);
if (legId == null || token == null) {
NexusLog.warn('voice: answer join_failed (leg=$legId token=${token != null}) — ${myLeg?['error']}');
_fail(call.sessionId, 'join_failed');
return;
}
_set(current.value!.copyWith(legId: legId));
NexusLog.info('voice: answered — joining media room "${token.room}" at ${token.url}');
await _engine.connect(token);
await _post('/partner/voice/legs/answer', {'legId': legId});
final sid = call.sessionId;
await _guard(() => _callKit.reportConnected(sid));
_startPresence();
// If the caller already hung up, no remote will ever join this room — don't
// sit on "connecting" forever. End it if nobody appears shortly.
_armConnectTimeout(sid);
} catch (e) {
NexusLog.error('voice.answer failed: $e');
_fail(call.sessionId, 'error');
}
}