1package eu.siacs.conversations.xmpp.jingle;
2
3import android.content.Intent;
4import android.telecom.TelecomManager;
5import android.telecom.VideoProfile;
6import android.util.Log;
7
8import androidx.annotation.NonNull;
9import androidx.annotation.Nullable;
10
11import com.google.common.base.Joiner;
12import com.google.common.base.Optional;
13import com.google.common.base.Preconditions;
14import com.google.common.base.Stopwatch;
15import com.google.common.base.Strings;
16import com.google.common.base.Throwables;
17import com.google.common.collect.Collections2;
18import com.google.common.collect.ImmutableMultimap;
19import com.google.common.collect.ImmutableSet;
20import com.google.common.collect.Iterables;
21import com.google.common.collect.Maps;
22import com.google.common.collect.Sets;
23import com.google.common.util.concurrent.FutureCallback;
24import com.google.common.util.concurrent.Futures;
25import com.google.common.util.concurrent.ListenableFuture;
26import com.google.common.util.concurrent.MoreExecutors;
27
28import eu.siacs.conversations.BuildConfig;
29import eu.siacs.conversations.Config;
30import eu.siacs.conversations.crypto.axolotl.AxolotlService;
31import eu.siacs.conversations.crypto.axolotl.CryptoFailedException;
32import eu.siacs.conversations.crypto.axolotl.FingerprintStatus;
33import eu.siacs.conversations.entities.Account;
34import eu.siacs.conversations.entities.Conversation;
35import eu.siacs.conversations.entities.Conversational;
36import eu.siacs.conversations.entities.Message;
37import eu.siacs.conversations.entities.RtpSessionStatus;
38import eu.siacs.conversations.services.CallIntegration;
39import eu.siacs.conversations.ui.RtpSessionActivity;
40import eu.siacs.conversations.xml.Element;
41import eu.siacs.conversations.xml.Namespace;
42import eu.siacs.conversations.xmpp.Jid;
43import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
44import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
45import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
46import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
47import eu.siacs.conversations.xmpp.jingle.stanzas.Proceed;
48import eu.siacs.conversations.xmpp.jingle.stanzas.Propose;
49import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
50import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
51import eu.siacs.conversations.xmpp.stanzas.IqPacket;
52import eu.siacs.conversations.xmpp.stanzas.MessagePacket;
53
54import org.webrtc.EglBase;
55import org.webrtc.IceCandidate;
56import org.webrtc.PeerConnection;
57import org.webrtc.VideoTrack;
58
59import java.util.Arrays;
60import java.util.Collection;
61import java.util.Collections;
62import java.util.LinkedList;
63import java.util.List;
64import java.util.Map;
65import java.util.Queue;
66import java.util.Set;
67import java.util.concurrent.ExecutionException;
68import java.util.concurrent.ScheduledFuture;
69import java.util.concurrent.TimeUnit;
70
71public class JingleRtpConnection extends AbstractJingleConnection
72 implements WebRTCWrapper.EventCallback, CallIntegration.Callback, OngoingRtpSession {
73
74 // TODO consider adding State.SESSION_INITIALIZED to ongoing call states for direct init mode
75 public static final List<State> STATES_SHOWING_ONGOING_CALL =
76 Arrays.asList(
77 State.PROPOSED,
78 State.PROCEED,
79 State.SESSION_INITIALIZED_PRE_APPROVED,
80 State.SESSION_ACCEPTED);
81 private static final long BUSY_TIME_OUT = 30;
82
83 private final WebRTCWrapper webRTCWrapper = new WebRTCWrapper(this);
84 private final Queue<
85 Map.Entry<String, DescriptionTransport<RtpDescription, IceUdpTransportInfo>>>
86 pendingIceCandidates = new LinkedList<>();
87 private final OmemoVerification omemoVerification = new OmemoVerification();
88 private final CallIntegration callIntegration;
89 private final Message message;
90
91 private Set<Media> proposedMedia;
92 private RtpContentMap initiatorRtpContentMap;
93 private RtpContentMap responderRtpContentMap;
94 private RtpContentMap incomingContentAdd;
95 private RtpContentMap outgoingContentAdd;
96 private IceUdpTransportInfo.Setup peerDtlsSetup;
97 private final Stopwatch sessionDuration = Stopwatch.createUnstarted();
98 private final Queue<PeerConnection.PeerConnectionState> stateHistory = new LinkedList<>();
99 private ScheduledFuture<?> ringingTimeoutFuture;
100
101 JingleRtpConnection(
102 final JingleConnectionManager jingleConnectionManager,
103 final Id id,
104 final Jid initiator) {
105 this(
106 jingleConnectionManager,
107 id,
108 initiator,
109 new CallIntegration(
110 jingleConnectionManager
111 .getXmppConnectionService()
112 .getApplicationContext()));
113 this.callIntegration.setAddress(
114 CallIntegration.address(id.with.asBareJid()), TelecomManager.PRESENTATION_ALLOWED);
115 final var contact = id.getContact();
116 this.callIntegration.setCallerDisplayName(
117 contact.getDisplayName(), TelecomManager.PRESENTATION_ALLOWED);
118 this.callIntegration.setInitialized();
119 }
120
121 JingleRtpConnection(
122 final JingleConnectionManager jingleConnectionManager,
123 final Id id,
124 final Jid initiator,
125 final CallIntegration callIntegration) {
126 super(jingleConnectionManager, id, initiator);
127 final Conversation conversation =
128 jingleConnectionManager
129 .getXmppConnectionService()
130 .findOrCreateConversation(id.account, id.with.asBareJid(), false, false);
131 this.message =
132 new Message(
133 conversation,
134 isInitiator() ? Message.STATUS_SEND : Message.STATUS_RECEIVED,
135 Message.TYPE_RTP_SESSION,
136 id.sessionId);
137 this.callIntegration = callIntegration;
138 this.callIntegration.setCallback(this);
139 }
140
141 @Override
142 synchronized void deliverPacket(final JinglePacket jinglePacket) {
143 switch (jinglePacket.getAction()) {
144 case SESSION_INITIATE -> receiveSessionInitiate(jinglePacket);
145 case TRANSPORT_INFO -> receiveTransportInfo(jinglePacket);
146 case SESSION_ACCEPT -> receiveSessionAccept(jinglePacket);
147 case SESSION_TERMINATE -> receiveSessionTerminate(jinglePacket);
148 case CONTENT_ADD -> receiveContentAdd(jinglePacket);
149 case CONTENT_ACCEPT -> receiveContentAccept(jinglePacket);
150 case CONTENT_REJECT -> receiveContentReject(jinglePacket);
151 case CONTENT_REMOVE -> receiveContentRemove(jinglePacket);
152 case CONTENT_MODIFY -> receiveContentModify(jinglePacket);
153 default -> {
154 respondOk(jinglePacket);
155 Log.d(
156 Config.LOGTAG,
157 String.format(
158 "%s: received unhandled jingle action %s",
159 id.account.getJid().asBareJid(), jinglePacket.getAction()));
160 }
161 }
162 }
163
164 @Override
165 synchronized void notifyRebound() {
166 if (isTerminated()) {
167 return;
168 }
169 webRTCWrapper.close();
170 if (isResponder() && isInState(State.PROPOSED, State.SESSION_INITIALIZED)) {
171 xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
172 }
173 if (isInState(
174 State.SESSION_INITIALIZED,
175 State.SESSION_INITIALIZED_PRE_APPROVED,
176 State.SESSION_ACCEPTED)) {
177 // we might have already changed resources (full jid) at this point; so this might not
178 // even reach the other party
179 sendSessionTerminate(Reason.CONNECTIVITY_ERROR);
180 } else {
181 transitionOrThrow(State.TERMINATED_CONNECTIVITY_ERROR);
182 finish();
183 }
184 }
185
186 private void receiveSessionTerminate(final JinglePacket jinglePacket) {
187 respondOk(jinglePacket);
188 final JinglePacket.ReasonWrapper wrapper = jinglePacket.getReason();
189 final State previous = this.state;
190 Log.d(
191 Config.LOGTAG,
192 id.account.getJid().asBareJid()
193 + ": received session terminate reason="
194 + wrapper.reason
195 + "("
196 + Strings.nullToEmpty(wrapper.text)
197 + ") while in state "
198 + previous);
199 if (TERMINATED.contains(previous)) {
200 Log.d(
201 Config.LOGTAG,
202 id.account.getJid().asBareJid()
203 + ": ignoring session terminate because already in "
204 + previous);
205 return;
206 }
207 webRTCWrapper.close();
208 final State target = reasonToState(wrapper.reason);
209 transitionOrThrow(target);
210 writeLogMessage(target);
211 if (previous == State.PROPOSED || previous == State.SESSION_INITIALIZED) {
212 xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
213 }
214 finish();
215 }
216
217 private void receiveTransportInfo(final JinglePacket jinglePacket) {
218 // Due to the asynchronicity of processing session-init we might move from NULL|PROCEED to
219 // INITIALIZED only after transport-info has been received
220 if (isInState(
221 State.NULL,
222 State.PROCEED,
223 State.SESSION_INITIALIZED,
224 State.SESSION_INITIALIZED_PRE_APPROVED,
225 State.SESSION_ACCEPTED)) {
226 final RtpContentMap contentMap;
227 try {
228 contentMap = RtpContentMap.of(jinglePacket);
229 } catch (final IllegalArgumentException | NullPointerException e) {
230 Log.d(
231 Config.LOGTAG,
232 id.account.getJid().asBareJid()
233 + ": improperly formatted contents; ignoring",
234 e);
235 respondOk(jinglePacket);
236 return;
237 }
238 receiveTransportInfo(jinglePacket, contentMap);
239 } else {
240 if (isTerminated()) {
241 respondOk(jinglePacket);
242 Log.d(
243 Config.LOGTAG,
244 id.account.getJid().asBareJid()
245 + ": ignoring out-of-order transport info; we where already terminated");
246 } else {
247 Log.d(
248 Config.LOGTAG,
249 id.account.getJid().asBareJid()
250 + ": received transport info while in state="
251 + this.state);
252 terminateWithOutOfOrder(jinglePacket);
253 }
254 }
255 }
256
257 private void receiveTransportInfo(
258 final JinglePacket jinglePacket, final RtpContentMap contentMap) {
259 final Set<Map.Entry<String, DescriptionTransport<RtpDescription, IceUdpTransportInfo>>>
260 candidates = contentMap.contents.entrySet();
261 final RtpContentMap remote = getRemoteContentMap();
262 final Set<String> remoteContentIds =
263 remote == null ? Collections.emptySet() : remote.contents.keySet();
264 if (Collections.disjoint(remoteContentIds, contentMap.contents.keySet())) {
265 Log.d(
266 Config.LOGTAG,
267 "received transport-info for unknown contents "
268 + contentMap.contents.keySet()
269 + " (known: "
270 + remoteContentIds
271 + ")");
272 respondOk(jinglePacket);
273 pendingIceCandidates.addAll(candidates);
274 return;
275 }
276 if (this.state != State.SESSION_ACCEPTED) {
277 Log.d(Config.LOGTAG, "received transport-info prematurely. adding to backlog");
278 respondOk(jinglePacket);
279 pendingIceCandidates.addAll(candidates);
280 return;
281 }
282 // zero candidates + modified credentials are an ICE restart offer
283 if (checkForIceRestart(jinglePacket, contentMap)) {
284 return;
285 }
286 respondOk(jinglePacket);
287 try {
288 processCandidates(candidates);
289 } catch (final WebRTCWrapper.PeerConnectionNotInitialized e) {
290 Log.w(
291 Config.LOGTAG,
292 id.account.getJid().asBareJid()
293 + ": PeerConnection was not initialized when processing transport info. this usually indicates a race condition that can be ignored");
294 }
295 }
296
297 private void receiveContentAdd(final JinglePacket jinglePacket) {
298 final RtpContentMap modification;
299 try {
300 modification = RtpContentMap.of(jinglePacket);
301 modification.requireContentDescriptions();
302 } catch (final RuntimeException e) {
303 Log.d(
304 Config.LOGTAG,
305 id.getAccount().getJid().asBareJid() + ": improperly formatted contents",
306 Throwables.getRootCause(e));
307 respondOk(jinglePacket);
308 webRTCWrapper.close();
309 sendSessionTerminate(Reason.of(e), e.getMessage());
310 return;
311 }
312 if (isInState(State.SESSION_ACCEPTED)) {
313 final boolean hasFullTransportInfo = modification.hasFullTransportInfo();
314 final ListenableFuture<RtpContentMap> future =
315 receiveRtpContentMap(
316 modification,
317 this.omemoVerification.hasFingerprint() && hasFullTransportInfo);
318 Futures.addCallback(
319 future,
320 new FutureCallback<>() {
321 @Override
322 public void onSuccess(final RtpContentMap rtpContentMap) {
323 receiveContentAdd(jinglePacket, rtpContentMap);
324 }
325
326 @Override
327 public void onFailure(@NonNull Throwable throwable) {
328 respondOk(jinglePacket);
329 final Throwable rootCause = Throwables.getRootCause(throwable);
330 Log.d(
331 Config.LOGTAG,
332 id.account.getJid().asBareJid()
333 + ": improperly formatted contents in content-add",
334 throwable);
335 webRTCWrapper.close();
336 sendSessionTerminate(
337 Reason.ofThrowable(rootCause), rootCause.getMessage());
338 }
339 },
340 MoreExecutors.directExecutor());
341 } else {
342 terminateWithOutOfOrder(jinglePacket);
343 }
344 }
345
346 private void receiveContentAdd(
347 final JinglePacket jinglePacket, final RtpContentMap modification) {
348 final RtpContentMap remote = getRemoteContentMap();
349 if (!Collections.disjoint(modification.getNames(), remote.getNames())) {
350 respondOk(jinglePacket);
351 this.webRTCWrapper.close();
352 sendSessionTerminate(
353 Reason.FAILED_APPLICATION,
354 String.format(
355 "contents with names %s already exists",
356 Joiner.on(", ").join(modification.getNames())));
357 return;
358 }
359 final ContentAddition contentAddition =
360 ContentAddition.of(ContentAddition.Direction.INCOMING, modification);
361
362 final RtpContentMap outgoing = this.outgoingContentAdd;
363 final Set<ContentAddition.Summary> outgoingContentAddSummary =
364 outgoing == null ? Collections.emptySet() : ContentAddition.summary(outgoing);
365
366 if (outgoingContentAddSummary.equals(contentAddition.summary)) {
367 if (isInitiator()) {
368 Log.d(
369 Config.LOGTAG,
370 id.getAccount().getJid().asBareJid()
371 + ": respond with tie break to matching content-add offer");
372 respondWithTieBreak(jinglePacket);
373 } else {
374 Log.d(
375 Config.LOGTAG,
376 id.getAccount().getJid().asBareJid()
377 + ": automatically accept matching content-add offer");
378 acceptContentAdd(contentAddition.summary, modification);
379 }
380 return;
381 }
382
383 // once we can display multiple video tracks we can be more loose with this condition
384 // theoretically it should also be fine to automatically accept audio only contents
385 if (Media.audioOnly(remote.getMedia()) && Media.videoOnly(contentAddition.media())) {
386 Log.d(
387 Config.LOGTAG,
388 id.getAccount().getJid().asBareJid() + ": received " + contentAddition);
389 this.incomingContentAdd = modification;
390 respondOk(jinglePacket);
391 updateEndUserState();
392 } else {
393 respondOk(jinglePacket);
394 // TODO do we want to add a reason?
395 rejectContentAdd(modification);
396 }
397 }
398
399 private void receiveContentAccept(final JinglePacket jinglePacket) {
400 final RtpContentMap receivedContentAccept;
401 try {
402 receivedContentAccept = RtpContentMap.of(jinglePacket);
403 receivedContentAccept.requireContentDescriptions();
404 } catch (final RuntimeException e) {
405 Log.d(
406 Config.LOGTAG,
407 id.getAccount().getJid().asBareJid() + ": improperly formatted contents",
408 Throwables.getRootCause(e));
409 respondOk(jinglePacket);
410 webRTCWrapper.close();
411 sendSessionTerminate(Reason.of(e), e.getMessage());
412 return;
413 }
414
415 final RtpContentMap outgoingContentAdd = this.outgoingContentAdd;
416 if (outgoingContentAdd == null) {
417 Log.d(Config.LOGTAG, "received content-accept when we had no outgoing content add");
418 terminateWithOutOfOrder(jinglePacket);
419 return;
420 }
421 final Set<ContentAddition.Summary> ourSummary = ContentAddition.summary(outgoingContentAdd);
422 if (ourSummary.equals(ContentAddition.summary(receivedContentAccept))) {
423 this.outgoingContentAdd = null;
424 respondOk(jinglePacket);
425 final boolean hasFullTransportInfo = receivedContentAccept.hasFullTransportInfo();
426 final ListenableFuture<RtpContentMap> future =
427 receiveRtpContentMap(
428 receivedContentAccept,
429 this.omemoVerification.hasFingerprint() && hasFullTransportInfo);
430 Futures.addCallback(
431 future,
432 new FutureCallback<>() {
433 @Override
434 public void onSuccess(final RtpContentMap result) {
435 receiveContentAccept(result);
436 }
437
438 @Override
439 public void onFailure(@NonNull final Throwable throwable) {
440 webRTCWrapper.close();
441 sendSessionTerminate(
442 Reason.ofThrowable(throwable), throwable.getMessage());
443 }
444 },
445 MoreExecutors.directExecutor());
446 } else {
447 Log.d(Config.LOGTAG, "received content-accept did not match our outgoing content-add");
448 terminateWithOutOfOrder(jinglePacket);
449 }
450 }
451
452 private void receiveContentAccept(final RtpContentMap receivedContentAccept) {
453 final IceUdpTransportInfo.Setup peerDtlsSetup = getPeerDtlsSetup();
454 final RtpContentMap modifiedContentMap =
455 getRemoteContentMap().addContent(receivedContentAccept, peerDtlsSetup);
456
457 setRemoteContentMap(modifiedContentMap);
458
459 final SessionDescription answer = SessionDescription.of(modifiedContentMap, isResponder());
460
461 final org.webrtc.SessionDescription sdp =
462 new org.webrtc.SessionDescription(
463 org.webrtc.SessionDescription.Type.ANSWER, answer.toString());
464
465 try {
466 this.webRTCWrapper.setRemoteDescription(sdp).get();
467 } catch (final Exception e) {
468 final Throwable cause = Throwables.getRootCause(e);
469 Log.d(
470 Config.LOGTAG,
471 id.getAccount().getJid().asBareJid()
472 + ": unable to set remote description after receiving content-accept",
473 cause);
474 webRTCWrapper.close();
475 sendSessionTerminate(Reason.FAILED_APPLICATION, cause.getMessage());
476 return;
477 }
478 Log.d(
479 Config.LOGTAG,
480 id.getAccount().getJid().asBareJid()
481 + ": remote has accepted content-add "
482 + ContentAddition.summary(receivedContentAccept));
483 processCandidates(receivedContentAccept.contents.entrySet());
484 updateEndUserState();
485 }
486
487 private void receiveContentModify(final JinglePacket jinglePacket) {
488 if (this.state != State.SESSION_ACCEPTED) {
489 terminateWithOutOfOrder(jinglePacket);
490 return;
491 }
492 final Map<String, Content.Senders> modification =
493 Maps.transformEntries(
494 jinglePacket.getJingleContents(), (key, value) -> value.getSenders());
495 final boolean isInitiator = isInitiator();
496 final RtpContentMap currentOutgoing = this.outgoingContentAdd;
497 final RtpContentMap remoteContentMap = this.getRemoteContentMap();
498 final Set<String> currentOutgoingMediaIds =
499 currentOutgoing == null
500 ? Collections.emptySet()
501 : currentOutgoing.contents.keySet();
502 Log.d(Config.LOGTAG, "receiveContentModification(" + modification + ")");
503 if (currentOutgoing != null && currentOutgoingMediaIds.containsAll(modification.keySet())) {
504 respondOk(jinglePacket);
505 final RtpContentMap modifiedContentMap;
506 try {
507 modifiedContentMap =
508 currentOutgoing.modifiedSendersChecked(isInitiator, modification);
509 } catch (final IllegalArgumentException e) {
510 webRTCWrapper.close();
511 sendSessionTerminate(Reason.FAILED_APPLICATION, e.getMessage());
512 return;
513 }
514 this.outgoingContentAdd = modifiedContentMap;
515 Log.d(
516 Config.LOGTAG,
517 id.account.getJid().asBareJid()
518 + ": processed content-modification for pending content-add");
519 } else if (remoteContentMap != null
520 && remoteContentMap.contents.keySet().containsAll(modification.keySet())) {
521 respondOk(jinglePacket);
522 final RtpContentMap modifiedRemoteContentMap;
523 try {
524 modifiedRemoteContentMap =
525 remoteContentMap.modifiedSendersChecked(isInitiator, modification);
526 } catch (final IllegalArgumentException e) {
527 webRTCWrapper.close();
528 sendSessionTerminate(Reason.FAILED_APPLICATION, e.getMessage());
529 return;
530 }
531 final SessionDescription offer;
532 try {
533 offer = SessionDescription.of(modifiedRemoteContentMap, isResponder());
534 } catch (final IllegalArgumentException | NullPointerException e) {
535 Log.d(
536 Config.LOGTAG,
537 id.getAccount().getJid().asBareJid()
538 + ": unable convert offer from content-modify to SDP",
539 e);
540 webRTCWrapper.close();
541 sendSessionTerminate(Reason.FAILED_APPLICATION, e.getMessage());
542 return;
543 }
544 Log.d(
545 Config.LOGTAG,
546 id.account.getJid().asBareJid() + ": auto accepting content-modification");
547 this.autoAcceptContentModify(modifiedRemoteContentMap, offer);
548 } else {
549 Log.d(Config.LOGTAG, "received unsupported content modification " + modification);
550 respondWithItemNotFound(jinglePacket);
551 }
552 }
553
554 private void autoAcceptContentModify(
555 final RtpContentMap modifiedRemoteContentMap, final SessionDescription offer) {
556 this.setRemoteContentMap(modifiedRemoteContentMap);
557 final org.webrtc.SessionDescription sdp =
558 new org.webrtc.SessionDescription(
559 org.webrtc.SessionDescription.Type.OFFER, offer.toString());
560 try {
561 this.webRTCWrapper.setRemoteDescription(sdp).get();
562 // auto accept is only done when we already have tracks
563 final SessionDescription answer = setLocalSessionDescription();
564 final RtpContentMap rtpContentMap = RtpContentMap.of(answer, isInitiator());
565 modifyLocalContentMap(rtpContentMap);
566 // we do not need to send an answer but do we have to resend the candidates currently in
567 // SDP?
568 // resendCandidatesFromSdp(answer);
569 webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
570 } catch (final Exception e) {
571 Log.d(Config.LOGTAG, "unable to accept content add", Throwables.getRootCause(e));
572 webRTCWrapper.close();
573 sendSessionTerminate(Reason.FAILED_APPLICATION);
574 }
575 }
576
577 private static ImmutableMultimap<String, IceUdpTransportInfo.Candidate> parseCandidates(
578 final SessionDescription answer) {
579 final ImmutableMultimap.Builder<String, IceUdpTransportInfo.Candidate> candidateBuilder =
580 new ImmutableMultimap.Builder<>();
581 for (final SessionDescription.Media media : answer.media) {
582 final String mid = Iterables.getFirst(media.attributes.get("mid"), null);
583 if (Strings.isNullOrEmpty(mid)) {
584 continue;
585 }
586 for (final String sdpCandidate : media.attributes.get("candidate")) {
587 final IceUdpTransportInfo.Candidate candidate =
588 IceUdpTransportInfo.Candidate.fromSdpAttributeValue(sdpCandidate, null);
589 if (candidate != null) {
590 candidateBuilder.put(mid, candidate);
591 }
592 }
593 }
594 return candidateBuilder.build();
595 }
596
597 private void receiveContentReject(final JinglePacket jinglePacket) {
598 final RtpContentMap receivedContentReject;
599 try {
600 receivedContentReject = RtpContentMap.of(jinglePacket);
601 } catch (final RuntimeException e) {
602 Log.d(
603 Config.LOGTAG,
604 id.getAccount().getJid().asBareJid() + ": improperly formatted contents",
605 Throwables.getRootCause(e));
606 respondOk(jinglePacket);
607 this.webRTCWrapper.close();
608 sendSessionTerminate(Reason.of(e), e.getMessage());
609 return;
610 }
611
612 final RtpContentMap outgoingContentAdd = this.outgoingContentAdd;
613 if (outgoingContentAdd == null) {
614 Log.d(Config.LOGTAG, "received content-reject when we had no outgoing content add");
615 terminateWithOutOfOrder(jinglePacket);
616 return;
617 }
618 final Set<ContentAddition.Summary> ourSummary = ContentAddition.summary(outgoingContentAdd);
619 if (ourSummary.equals(ContentAddition.summary(receivedContentReject))) {
620 this.outgoingContentAdd = null;
621 respondOk(jinglePacket);
622 Log.d(Config.LOGTAG, jinglePacket.toString());
623 receiveContentReject(ourSummary);
624 } else {
625 Log.d(Config.LOGTAG, "received content-reject did not match our outgoing content-add");
626 terminateWithOutOfOrder(jinglePacket);
627 }
628 }
629
630 private void receiveContentReject(final Set<ContentAddition.Summary> summary) {
631 try {
632 this.webRTCWrapper.removeTrack(Media.VIDEO);
633 final RtpContentMap localContentMap = customRollback();
634 modifyLocalContentMap(localContentMap);
635 } catch (final Exception e) {
636 final Throwable cause = Throwables.getRootCause(e);
637 Log.d(
638 Config.LOGTAG,
639 id.getAccount().getJid().asBareJid()
640 + ": unable to rollback local description after receiving content-reject",
641 cause);
642 webRTCWrapper.close();
643 sendSessionTerminate(Reason.FAILED_APPLICATION, cause.getMessage());
644 return;
645 }
646 Log.d(
647 Config.LOGTAG,
648 id.getAccount().getJid().asBareJid()
649 + ": remote has rejected our content-add "
650 + summary);
651 }
652
653 private void receiveContentRemove(final JinglePacket jinglePacket) {
654 final RtpContentMap receivedContentRemove;
655 try {
656 receivedContentRemove = RtpContentMap.of(jinglePacket);
657 receivedContentRemove.requireContentDescriptions();
658 } catch (final RuntimeException e) {
659 Log.d(
660 Config.LOGTAG,
661 id.getAccount().getJid().asBareJid() + ": improperly formatted contents",
662 Throwables.getRootCause(e));
663 respondOk(jinglePacket);
664 this.webRTCWrapper.close();
665 sendSessionTerminate(Reason.of(e), e.getMessage());
666 return;
667 }
668 respondOk(jinglePacket);
669 receiveContentRemove(receivedContentRemove);
670 }
671
672 private void receiveContentRemove(final RtpContentMap receivedContentRemove) {
673 final RtpContentMap incomingContentAdd = this.incomingContentAdd;
674 final Set<ContentAddition.Summary> contentAddSummary =
675 incomingContentAdd == null
676 ? Collections.emptySet()
677 : ContentAddition.summary(incomingContentAdd);
678 final Set<ContentAddition.Summary> removeSummary =
679 ContentAddition.summary(receivedContentRemove);
680 if (contentAddSummary.equals(removeSummary)) {
681 this.incomingContentAdd = null;
682 updateEndUserState();
683 } else {
684 webRTCWrapper.close();
685 sendSessionTerminate(
686 Reason.FAILED_APPLICATION,
687 String.format(
688 "%s only supports %s as a means to retract a not yet accepted %s",
689 BuildConfig.APP_NAME,
690 JinglePacket.Action.CONTENT_REMOVE,
691 JinglePacket.Action.CONTENT_ADD));
692 }
693 }
694
695 public synchronized void retractContentAdd() {
696 final RtpContentMap outgoingContentAdd = this.outgoingContentAdd;
697 if (outgoingContentAdd == null) {
698 throw new IllegalStateException("Not outgoing content add");
699 }
700 try {
701 webRTCWrapper.removeTrack(Media.VIDEO);
702 final RtpContentMap localContentMap = customRollback();
703 modifyLocalContentMap(localContentMap);
704 } catch (final Exception e) {
705 final Throwable cause = Throwables.getRootCause(e);
706 Log.d(
707 Config.LOGTAG,
708 id.getAccount().getJid().asBareJid()
709 + ": unable to rollback local description after trying to retract content-add",
710 cause);
711 webRTCWrapper.close();
712 sendSessionTerminate(Reason.FAILED_APPLICATION, cause.getMessage());
713 return;
714 }
715 this.outgoingContentAdd = null;
716 final JinglePacket retract =
717 outgoingContentAdd
718 .toStub()
719 .toJinglePacket(JinglePacket.Action.CONTENT_REMOVE, id.sessionId);
720 this.send(retract);
721 Log.d(
722 Config.LOGTAG,
723 id.getAccount().getJid()
724 + ": retract content-add "
725 + ContentAddition.summary(outgoingContentAdd));
726 }
727
728 private RtpContentMap customRollback() throws ExecutionException, InterruptedException {
729 final SessionDescription sdp = setLocalSessionDescription();
730 final RtpContentMap localRtpContentMap = RtpContentMap.of(sdp, isInitiator());
731 final SessionDescription answer = generateFakeResponse(localRtpContentMap);
732 this.webRTCWrapper
733 .setRemoteDescription(
734 new org.webrtc.SessionDescription(
735 org.webrtc.SessionDescription.Type.ANSWER, answer.toString()))
736 .get();
737 return localRtpContentMap;
738 }
739
740 private SessionDescription generateFakeResponse(final RtpContentMap localContentMap) {
741 final RtpContentMap currentRemote = getRemoteContentMap();
742 final RtpContentMap.Diff diff = currentRemote.diff(localContentMap);
743 if (diff.isEmpty()) {
744 throw new IllegalStateException(
745 "Unexpected rollback condition. No difference between local and remote");
746 }
747 final RtpContentMap patch = localContentMap.toContentModification(diff.added);
748 if (ImmutableSet.of(Content.Senders.NONE).equals(patch.getSenders())) {
749 final RtpContentMap nextRemote =
750 currentRemote.addContent(
751 patch.modifiedSenders(Content.Senders.NONE), getPeerDtlsSetup());
752 return SessionDescription.of(nextRemote, isResponder());
753 }
754 throw new IllegalStateException(
755 "Unexpected rollback condition. Senders were not uniformly none");
756 }
757
758 public synchronized void acceptContentAdd(
759 @NonNull final Set<ContentAddition.Summary> contentAddition) {
760 final RtpContentMap incomingContentAdd = this.incomingContentAdd;
761 if (incomingContentAdd == null) {
762 throw new IllegalStateException("No incoming content add");
763 }
764
765 if (contentAddition.equals(ContentAddition.summary(incomingContentAdd))) {
766 this.incomingContentAdd = null;
767 final Set<Content.Senders> senders = incomingContentAdd.getSenders();
768 Log.d(Config.LOGTAG, "senders of incoming content-add: " + senders);
769 if (senders.equals(Content.Senders.receiveOnly(isInitiator()))) {
770 Log.d(
771 Config.LOGTAG,
772 "content addition is receive only. we want to upgrade to 'both'");
773 final RtpContentMap modifiedSenders =
774 incomingContentAdd.modifiedSenders(Content.Senders.BOTH);
775 final JinglePacket proposedContentModification =
776 modifiedSenders
777 .toStub()
778 .toJinglePacket(JinglePacket.Action.CONTENT_MODIFY, id.sessionId);
779 proposedContentModification.setTo(id.with);
780 xmppConnectionService.sendIqPacket(
781 id.account,
782 proposedContentModification,
783 (account, response) -> {
784 if (response.getType() == IqPacket.TYPE.RESULT) {
785 Log.d(
786 Config.LOGTAG,
787 id.account.getJid().asBareJid()
788 + ": remote has accepted our upgrade to senders=both");
789 acceptContentAdd(
790 ContentAddition.summary(modifiedSenders), modifiedSenders);
791 } else {
792 Log.d(
793 Config.LOGTAG,
794 id.account.getJid().asBareJid()
795 + ": remote has rejected our upgrade to senders=both");
796 acceptContentAdd(contentAddition, incomingContentAdd);
797 }
798 });
799 } else {
800 acceptContentAdd(contentAddition, incomingContentAdd);
801 }
802 } else {
803 throw new IllegalStateException(
804 "Accepted content add does not match pending content-add");
805 }
806 }
807
808 private void acceptContentAdd(
809 @NonNull final Set<ContentAddition.Summary> contentAddition,
810 final RtpContentMap incomingContentAdd) {
811 final IceUdpTransportInfo.Setup setup = getPeerDtlsSetup();
812 final RtpContentMap modifiedContentMap =
813 getRemoteContentMap().addContent(incomingContentAdd, setup);
814 this.setRemoteContentMap(modifiedContentMap);
815
816 final SessionDescription offer;
817 try {
818 offer = SessionDescription.of(modifiedContentMap, isResponder());
819 } catch (final IllegalArgumentException | NullPointerException e) {
820 Log.d(
821 Config.LOGTAG,
822 id.getAccount().getJid().asBareJid()
823 + ": unable convert offer from content-add to SDP",
824 e);
825 webRTCWrapper.close();
826 sendSessionTerminate(Reason.FAILED_APPLICATION, e.getMessage());
827 return;
828 }
829 this.incomingContentAdd = null;
830 acceptContentAdd(contentAddition, offer);
831 }
832
833 private void acceptContentAdd(
834 final Set<ContentAddition.Summary> contentAddition, final SessionDescription offer) {
835 final org.webrtc.SessionDescription sdp =
836 new org.webrtc.SessionDescription(
837 org.webrtc.SessionDescription.Type.OFFER, offer.toString());
838 try {
839 this.webRTCWrapper.setRemoteDescription(sdp).get();
840
841 // TODO add tracks for 'media' where contentAddition.senders matches
842
843 // TODO if senders.sending(isInitiator())
844
845 this.webRTCWrapper.addTrack(Media.VIDEO);
846
847 // TODO add additional transceivers for recv only cases
848
849 final SessionDescription answer = setLocalSessionDescription();
850 final RtpContentMap rtpContentMap = RtpContentMap.of(answer, isInitiator());
851
852 final RtpContentMap contentAcceptMap =
853 rtpContentMap.toContentModification(
854 Collections2.transform(contentAddition, ca -> ca.name));
855
856 Log.d(
857 Config.LOGTAG,
858 id.getAccount().getJid().asBareJid()
859 + ": sending content-accept "
860 + ContentAddition.summary(contentAcceptMap));
861
862 addIceCandidatesFromBlackLog();
863
864 modifyLocalContentMap(rtpContentMap);
865 final ListenableFuture<RtpContentMap> future =
866 prepareOutgoingContentMap(contentAcceptMap);
867 Futures.addCallback(
868 future,
869 new FutureCallback<>() {
870 @Override
871 public void onSuccess(final RtpContentMap rtpContentMap) {
872 sendContentAccept(rtpContentMap);
873 webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
874 }
875
876 @Override
877 public void onFailure(@NonNull final Throwable throwable) {
878 failureToPerformAction(JinglePacket.Action.CONTENT_ACCEPT, throwable);
879 }
880 },
881 MoreExecutors.directExecutor());
882 } catch (final Exception e) {
883 Log.d(Config.LOGTAG, "unable to accept content add", Throwables.getRootCause(e));
884 webRTCWrapper.close();
885 sendSessionTerminate(Reason.FAILED_APPLICATION);
886 }
887 }
888
889 private void sendContentAccept(final RtpContentMap contentAcceptMap) {
890 final JinglePacket jinglePacket =
891 contentAcceptMap.toJinglePacket(JinglePacket.Action.CONTENT_ACCEPT, id.sessionId);
892 send(jinglePacket);
893 }
894
895 public synchronized void rejectContentAdd() {
896 final RtpContentMap incomingContentAdd = this.incomingContentAdd;
897 if (incomingContentAdd == null) {
898 throw new IllegalStateException("No incoming content add");
899 }
900 this.incomingContentAdd = null;
901 updateEndUserState();
902 rejectContentAdd(incomingContentAdd);
903 }
904
905 private void rejectContentAdd(final RtpContentMap contentMap) {
906 final JinglePacket jinglePacket =
907 contentMap
908 .toStub()
909 .toJinglePacket(JinglePacket.Action.CONTENT_REJECT, id.sessionId);
910 Log.d(
911 Config.LOGTAG,
912 id.getAccount().getJid().asBareJid()
913 + ": rejecting content "
914 + ContentAddition.summary(contentMap));
915 send(jinglePacket);
916 }
917
918 private boolean checkForIceRestart(
919 final JinglePacket jinglePacket, final RtpContentMap rtpContentMap) {
920 final RtpContentMap existing = getRemoteContentMap();
921 final Set<IceUdpTransportInfo.Credentials> existingCredentials;
922 final IceUdpTransportInfo.Credentials newCredentials;
923 try {
924 existingCredentials = existing.getCredentials();
925 newCredentials = rtpContentMap.getDistinctCredentials();
926 } catch (final IllegalStateException e) {
927 Log.d(Config.LOGTAG, "unable to gather credentials for comparison", e);
928 return false;
929 }
930 if (existingCredentials.contains(newCredentials)) {
931 return false;
932 }
933 // TODO an alternative approach is to check if we already got an iq result to our
934 // ICE-restart
935 // and if that's the case we are seeing an answer.
936 // This might be more spec compliant but also more error prone potentially
937 final boolean isSignalStateStable =
938 this.webRTCWrapper.getSignalingState() == PeerConnection.SignalingState.STABLE;
939 // TODO a stable signal state can be another indicator that we have an offer to restart ICE
940 final boolean isOffer = rtpContentMap.emptyCandidates();
941 final RtpContentMap restartContentMap;
942 try {
943 if (isOffer) {
944 Log.d(Config.LOGTAG, "received offer to restart ICE " + newCredentials);
945 restartContentMap =
946 existing.modifiedCredentials(
947 newCredentials, IceUdpTransportInfo.Setup.ACTPASS);
948 } else {
949 final IceUdpTransportInfo.Setup setup = getPeerDtlsSetup();
950 Log.d(
951 Config.LOGTAG,
952 "received confirmation of ICE restart"
953 + newCredentials
954 + " peer_setup="
955 + setup);
956 // DTLS setup attribute needs to be rewritten to reflect current peer state
957 // https://groups.google.com/g/discuss-webrtc/c/DfpIMwvUfeM
958 restartContentMap = existing.modifiedCredentials(newCredentials, setup);
959 }
960 if (applyIceRestart(jinglePacket, restartContentMap, isOffer)) {
961 return isOffer;
962 } else {
963 Log.d(Config.LOGTAG, "ignoring ICE restart. sending tie-break");
964 respondWithTieBreak(jinglePacket);
965 return true;
966 }
967 } catch (final Exception exception) {
968 respondOk(jinglePacket);
969 final Throwable rootCause = Throwables.getRootCause(exception);
970 if (rootCause instanceof WebRTCWrapper.PeerConnectionNotInitialized) {
971 // If this happens a termination is already in progress
972 Log.d(Config.LOGTAG, "ignoring PeerConnectionNotInitialized on ICE restart");
973 return true;
974 }
975 Log.d(Config.LOGTAG, "failure to apply ICE restart", rootCause);
976 webRTCWrapper.close();
977 sendSessionTerminate(Reason.ofThrowable(rootCause), rootCause.getMessage());
978 return true;
979 }
980 }
981
982 private IceUdpTransportInfo.Setup getPeerDtlsSetup() {
983 final IceUdpTransportInfo.Setup peerSetup = this.peerDtlsSetup;
984 if (peerSetup == null || peerSetup == IceUdpTransportInfo.Setup.ACTPASS) {
985 throw new IllegalStateException("Invalid peer setup");
986 }
987 return peerSetup;
988 }
989
990 private void storePeerDtlsSetup(final IceUdpTransportInfo.Setup setup) {
991 if (setup == null || setup == IceUdpTransportInfo.Setup.ACTPASS) {
992 throw new IllegalArgumentException("Trying to store invalid peer dtls setup");
993 }
994 this.peerDtlsSetup = setup;
995 }
996
997 private boolean applyIceRestart(
998 final JinglePacket jinglePacket,
999 final RtpContentMap restartContentMap,
1000 final boolean isOffer)
1001 throws ExecutionException, InterruptedException {
1002 final SessionDescription sessionDescription =
1003 SessionDescription.of(restartContentMap, isResponder());
1004 final org.webrtc.SessionDescription.Type type =
1005 isOffer
1006 ? org.webrtc.SessionDescription.Type.OFFER
1007 : org.webrtc.SessionDescription.Type.ANSWER;
1008 org.webrtc.SessionDescription sdp =
1009 new org.webrtc.SessionDescription(type, sessionDescription.toString());
1010 if (isOffer && webRTCWrapper.getSignalingState() != PeerConnection.SignalingState.STABLE) {
1011 if (isInitiator()) {
1012 // We ignore the offer and respond with tie-break. This will clause the responder
1013 // not to apply the content map
1014 return false;
1015 }
1016 }
1017 webRTCWrapper.setRemoteDescription(sdp).get();
1018 setRemoteContentMap(restartContentMap);
1019 if (isOffer) {
1020 final SessionDescription localSessionDescription = setLocalSessionDescription();
1021 setLocalContentMap(RtpContentMap.of(localSessionDescription, isInitiator()));
1022 // We need to respond OK before sending any candidates
1023 respondOk(jinglePacket);
1024 webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
1025 } else {
1026 storePeerDtlsSetup(restartContentMap.getDtlsSetup());
1027 }
1028 return true;
1029 }
1030
1031 private void processCandidates(
1032 final Set<Map.Entry<String, DescriptionTransport<RtpDescription, IceUdpTransportInfo>>>
1033 contents) {
1034 for (final Map.Entry<String, DescriptionTransport<RtpDescription, IceUdpTransportInfo>>
1035 content : contents) {
1036 processCandidate(content);
1037 }
1038 }
1039
1040 private void processCandidate(
1041 final Map.Entry<String, DescriptionTransport<RtpDescription, IceUdpTransportInfo>>
1042 content) {
1043 final RtpContentMap rtpContentMap = getRemoteContentMap();
1044 final List<String> indices = toIdentificationTags(rtpContentMap);
1045 final String sdpMid = content.getKey(); // aka content name
1046 final IceUdpTransportInfo transport = content.getValue().transport;
1047 final IceUdpTransportInfo.Credentials credentials = transport.getCredentials();
1048
1049 // TODO check that credentials remained the same
1050
1051 for (final IceUdpTransportInfo.Candidate candidate : transport.getCandidates()) {
1052 final String sdp;
1053 try {
1054 sdp = candidate.toSdpAttribute(credentials.ufrag);
1055 } catch (final IllegalArgumentException e) {
1056 Log.d(
1057 Config.LOGTAG,
1058 id.account.getJid().asBareJid()
1059 + ": ignoring invalid ICE candidate "
1060 + e.getMessage());
1061 continue;
1062 }
1063 final int mLineIndex = indices.indexOf(sdpMid);
1064 if (mLineIndex < 0) {
1065 Log.w(
1066 Config.LOGTAG,
1067 "mLineIndex not found for " + sdpMid + ". available indices " + indices);
1068 }
1069 final IceCandidate iceCandidate = new IceCandidate(sdpMid, mLineIndex, sdp);
1070 Log.d(Config.LOGTAG, "received candidate: " + iceCandidate);
1071 this.webRTCWrapper.addIceCandidate(iceCandidate);
1072 }
1073 }
1074
1075 private RtpContentMap getRemoteContentMap() {
1076 return isInitiator() ? this.responderRtpContentMap : this.initiatorRtpContentMap;
1077 }
1078
1079 private RtpContentMap getLocalContentMap() {
1080 return isInitiator() ? this.initiatorRtpContentMap : this.responderRtpContentMap;
1081 }
1082
1083 private List<String> toIdentificationTags(final RtpContentMap rtpContentMap) {
1084 final Group originalGroup = rtpContentMap.group;
1085 final List<String> identificationTags =
1086 originalGroup == null
1087 ? rtpContentMap.getNames()
1088 : originalGroup.getIdentificationTags();
1089 if (identificationTags.size() == 0) {
1090 Log.w(
1091 Config.LOGTAG,
1092 id.account.getJid().asBareJid()
1093 + ": no identification tags found in initial offer. we won't be able to calculate mLineIndices");
1094 }
1095 return identificationTags;
1096 }
1097
1098 private ListenableFuture<RtpContentMap> receiveRtpContentMap(
1099 final JinglePacket jinglePacket, final boolean expectVerification) {
1100 try {
1101 return receiveRtpContentMap(RtpContentMap.of(jinglePacket), expectVerification);
1102 } catch (final Exception e) {
1103 return Futures.immediateFailedFuture(e);
1104 }
1105 }
1106
1107 private ListenableFuture<RtpContentMap> receiveRtpContentMap(
1108 final RtpContentMap receivedContentMap, final boolean expectVerification) {
1109 Log.d(
1110 Config.LOGTAG,
1111 "receiveRtpContentMap("
1112 + receivedContentMap.getClass().getSimpleName()
1113 + ",expectVerification="
1114 + expectVerification
1115 + ")");
1116 if (receivedContentMap instanceof OmemoVerifiedRtpContentMap) {
1117 final ListenableFuture<AxolotlService.OmemoVerifiedPayload<RtpContentMap>> future =
1118 id.account
1119 .getAxolotlService()
1120 .decrypt((OmemoVerifiedRtpContentMap) receivedContentMap, id.with);
1121 return Futures.transform(
1122 future,
1123 omemoVerifiedPayload -> {
1124 // TODO test if an exception here triggers a correct abort
1125 omemoVerification.setOrEnsureEqual(omemoVerifiedPayload);
1126 Log.d(
1127 Config.LOGTAG,
1128 id.account.getJid().asBareJid()
1129 + ": received verifiable DTLS fingerprint via "
1130 + omemoVerification);
1131 return omemoVerifiedPayload.getPayload();
1132 },
1133 MoreExecutors.directExecutor());
1134 } else if (Config.REQUIRE_RTP_VERIFICATION || expectVerification) {
1135 return Futures.immediateFailedFuture(
1136 new SecurityException("DTLS fingerprint was unexpectedly not verifiable"));
1137 } else {
1138 return Futures.immediateFuture(receivedContentMap);
1139 }
1140 }
1141
1142 private void receiveSessionInitiate(final JinglePacket jinglePacket) {
1143 if (isInitiator()) {
1144 receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.SESSION_INITIATE);
1145 return;
1146 }
1147 final ListenableFuture<RtpContentMap> future = receiveRtpContentMap(jinglePacket, false);
1148 Futures.addCallback(
1149 future,
1150 new FutureCallback<>() {
1151 @Override
1152 public void onSuccess(@Nullable RtpContentMap rtpContentMap) {
1153 receiveSessionInitiate(jinglePacket, rtpContentMap);
1154 }
1155
1156 @Override
1157 public void onFailure(@NonNull final Throwable throwable) {
1158 respondOk(jinglePacket);
1159 sendSessionTerminate(Reason.ofThrowable(throwable), throwable.getMessage());
1160 }
1161 },
1162 MoreExecutors.directExecutor());
1163 }
1164
1165 private void receiveSessionInitiate(
1166 final JinglePacket jinglePacket, final RtpContentMap contentMap) {
1167 try {
1168 contentMap.requireContentDescriptions();
1169 contentMap.requireDTLSFingerprint(true);
1170 } catch (final RuntimeException e) {
1171 Log.d(
1172 Config.LOGTAG,
1173 id.account.getJid().asBareJid() + ": improperly formatted contents",
1174 Throwables.getRootCause(e));
1175 respondOk(jinglePacket);
1176 sendSessionTerminate(Reason.of(e), e.getMessage());
1177 return;
1178 }
1179 Log.d(
1180 Config.LOGTAG,
1181 "processing session-init with " + contentMap.contents.size() + " contents");
1182 final State target;
1183 if (this.state == State.PROCEED) {
1184 Preconditions.checkState(
1185 proposedMedia != null && proposedMedia.size() > 0,
1186 "proposed media must be set when processing pre-approved session-initiate");
1187 if (!this.proposedMedia.equals(contentMap.getMedia())) {
1188 sendSessionTerminate(
1189 Reason.SECURITY_ERROR,
1190 String.format(
1191 "Your session proposal (Jingle Message Initiation) included media %s but your session-initiate was %s",
1192 this.proposedMedia, contentMap.getMedia()));
1193 return;
1194 }
1195 target = State.SESSION_INITIALIZED_PRE_APPROVED;
1196 } else {
1197 target = State.SESSION_INITIALIZED;
1198 setProposedMedia(contentMap.getMedia());
1199 }
1200 if (transition(target, () -> this.initiatorRtpContentMap = contentMap)) {
1201 respondOk(jinglePacket);
1202 pendingIceCandidates.addAll(contentMap.contents.entrySet());
1203 if (target == State.SESSION_INITIALIZED_PRE_APPROVED) {
1204 Log.d(
1205 Config.LOGTAG,
1206 id.account.getJid().asBareJid()
1207 + ": automatically accepting session-initiate");
1208 sendSessionAccept();
1209 } else {
1210 Log.d(
1211 Config.LOGTAG,
1212 id.account.getJid().asBareJid()
1213 + ": received not pre-approved session-initiate. start ringing");
1214 startRinging();
1215 }
1216 } else {
1217 Log.d(
1218 Config.LOGTAG,
1219 String.format(
1220 "%s: received session-initiate while in state %s",
1221 id.account.getJid().asBareJid(), state));
1222 terminateWithOutOfOrder(jinglePacket);
1223 }
1224 }
1225
1226 private void receiveSessionAccept(final JinglePacket jinglePacket) {
1227 if (isResponder()) {
1228 receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.SESSION_ACCEPT);
1229 return;
1230 }
1231 final ListenableFuture<RtpContentMap> future =
1232 receiveRtpContentMap(jinglePacket, this.omemoVerification.hasFingerprint());
1233 Futures.addCallback(
1234 future,
1235 new FutureCallback<>() {
1236 @Override
1237 public void onSuccess(@Nullable RtpContentMap rtpContentMap) {
1238 receiveSessionAccept(jinglePacket, rtpContentMap);
1239 }
1240
1241 @Override
1242 public void onFailure(@NonNull final Throwable throwable) {
1243 respondOk(jinglePacket);
1244 Log.d(
1245 Config.LOGTAG,
1246 id.account.getJid().asBareJid()
1247 + ": improperly formatted contents in session-accept",
1248 throwable);
1249 webRTCWrapper.close();
1250 sendSessionTerminate(Reason.ofThrowable(throwable), throwable.getMessage());
1251 }
1252 },
1253 MoreExecutors.directExecutor());
1254 }
1255
1256 private void receiveSessionAccept(
1257 final JinglePacket jinglePacket, final RtpContentMap contentMap) {
1258 try {
1259 contentMap.requireContentDescriptions();
1260 contentMap.requireDTLSFingerprint();
1261 } catch (final RuntimeException e) {
1262 respondOk(jinglePacket);
1263 Log.d(
1264 Config.LOGTAG,
1265 id.account.getJid().asBareJid()
1266 + ": improperly formatted contents in session-accept",
1267 e);
1268 webRTCWrapper.close();
1269 sendSessionTerminate(Reason.of(e), e.getMessage());
1270 return;
1271 }
1272 final Set<Media> initiatorMedia = this.initiatorRtpContentMap.getMedia();
1273 if (!initiatorMedia.equals(contentMap.getMedia())) {
1274 sendSessionTerminate(
1275 Reason.SECURITY_ERROR,
1276 String.format(
1277 "Your session-included included media %s but our session-initiate was %s",
1278 this.proposedMedia, contentMap.getMedia()));
1279 return;
1280 }
1281 Log.d(
1282 Config.LOGTAG,
1283 "processing session-accept with " + contentMap.contents.size() + " contents");
1284 if (transition(State.SESSION_ACCEPTED)) {
1285 respondOk(jinglePacket);
1286 receiveSessionAccept(contentMap);
1287 } else {
1288 Log.d(
1289 Config.LOGTAG,
1290 String.format(
1291 "%s: received session-accept while in state %s",
1292 id.account.getJid().asBareJid(), state));
1293 respondOk(jinglePacket);
1294 }
1295 }
1296
1297 private void receiveSessionAccept(final RtpContentMap contentMap) {
1298 this.responderRtpContentMap = contentMap;
1299 this.storePeerDtlsSetup(contentMap.getDtlsSetup());
1300 final SessionDescription sessionDescription;
1301 try {
1302 sessionDescription = SessionDescription.of(contentMap, false);
1303 } catch (final IllegalArgumentException | NullPointerException e) {
1304 Log.d(
1305 Config.LOGTAG,
1306 id.account.getJid().asBareJid()
1307 + ": unable convert offer from session-accept to SDP",
1308 e);
1309 webRTCWrapper.close();
1310 sendSessionTerminate(Reason.FAILED_APPLICATION, e.getMessage());
1311 return;
1312 }
1313 final org.webrtc.SessionDescription answer =
1314 new org.webrtc.SessionDescription(
1315 org.webrtc.SessionDescription.Type.ANSWER, sessionDescription.toString());
1316 try {
1317 this.webRTCWrapper.setRemoteDescription(answer).get();
1318 } catch (final Exception e) {
1319 Log.d(
1320 Config.LOGTAG,
1321 id.account.getJid().asBareJid()
1322 + ": unable to set remote description after receiving session-accept",
1323 Throwables.getRootCause(e));
1324 webRTCWrapper.close();
1325 sendSessionTerminate(
1326 Reason.FAILED_APPLICATION, Throwables.getRootCause(e).getMessage());
1327 return;
1328 }
1329 processCandidates(contentMap.contents.entrySet());
1330 }
1331
1332 private void sendSessionAccept() {
1333 final RtpContentMap rtpContentMap = this.initiatorRtpContentMap;
1334 if (rtpContentMap == null) {
1335 throw new IllegalStateException("initiator RTP Content Map has not been set");
1336 }
1337 final SessionDescription offer;
1338 try {
1339 offer = SessionDescription.of(rtpContentMap, true);
1340 } catch (final IllegalArgumentException | NullPointerException e) {
1341 Log.d(
1342 Config.LOGTAG,
1343 id.account.getJid().asBareJid()
1344 + ": unable convert offer from session-initiate to SDP",
1345 e);
1346 webRTCWrapper.close();
1347 sendSessionTerminate(Reason.FAILED_APPLICATION, e.getMessage());
1348 return;
1349 }
1350 sendSessionAccept(rtpContentMap.getMedia(), offer);
1351 }
1352
1353 private void sendSessionAccept(final Set<Media> media, final SessionDescription offer) {
1354 discoverIceServers(iceServers -> sendSessionAccept(media, offer, iceServers));
1355 }
1356
1357 private synchronized void sendSessionAccept(
1358 final Set<Media> media,
1359 final SessionDescription offer,
1360 final List<PeerConnection.IceServer> iceServers) {
1361 if (isTerminated()) {
1362 Log.w(
1363 Config.LOGTAG,
1364 id.account.getJid().asBareJid()
1365 + ": ICE servers got discovered when session was already terminated. nothing to do.");
1366 return;
1367 }
1368 final boolean includeCandidates = remoteHasSdpOfferAnswer();
1369 try {
1370 setupWebRTC(media, iceServers, !includeCandidates);
1371 } catch (final WebRTCWrapper.InitializationException e) {
1372 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to initialize WebRTC");
1373 webRTCWrapper.close();
1374 sendSessionTerminate(Reason.FAILED_APPLICATION, e.getMessage());
1375 return;
1376 }
1377 final org.webrtc.SessionDescription sdp =
1378 new org.webrtc.SessionDescription(
1379 org.webrtc.SessionDescription.Type.OFFER, offer.toString());
1380 try {
1381 this.webRTCWrapper.setRemoteDescription(sdp).get();
1382 addIceCandidatesFromBlackLog();
1383 org.webrtc.SessionDescription webRTCSessionDescription =
1384 this.webRTCWrapper.setLocalDescription(includeCandidates).get();
1385 prepareSessionAccept(webRTCSessionDescription, includeCandidates);
1386 } catch (final Exception e) {
1387 failureToAcceptSession(e);
1388 }
1389 }
1390
1391 private void failureToAcceptSession(final Throwable throwable) {
1392 if (isTerminated()) {
1393 return;
1394 }
1395 final Throwable rootCause = Throwables.getRootCause(throwable);
1396 Log.d(Config.LOGTAG, "unable to send session accept", rootCause);
1397 webRTCWrapper.close();
1398 sendSessionTerminate(Reason.ofThrowable(rootCause), rootCause.getMessage());
1399 }
1400
1401 private void failureToPerformAction(
1402 final JinglePacket.Action action, final Throwable throwable) {
1403 if (isTerminated()) {
1404 return;
1405 }
1406 final Throwable rootCause = Throwables.getRootCause(throwable);
1407 Log.d(Config.LOGTAG, "unable to send " + action, rootCause);
1408 webRTCWrapper.close();
1409 sendSessionTerminate(Reason.ofThrowable(rootCause), rootCause.getMessage());
1410 }
1411
1412 private void addIceCandidatesFromBlackLog() {
1413 Map.Entry<String, DescriptionTransport<RtpDescription, IceUdpTransportInfo>> foo;
1414 while ((foo = this.pendingIceCandidates.poll()) != null) {
1415 processCandidate(foo);
1416 Log.d(
1417 Config.LOGTAG,
1418 id.account.getJid().asBareJid() + ": added candidate from back log");
1419 }
1420 }
1421
1422 private void prepareSessionAccept(
1423 final org.webrtc.SessionDescription webRTCSessionDescription,
1424 final boolean includeCandidates) {
1425 final SessionDescription sessionDescription =
1426 SessionDescription.parse(webRTCSessionDescription.description);
1427 final RtpContentMap respondingRtpContentMap = RtpContentMap.of(sessionDescription, false);
1428 final ImmutableMultimap<String, IceUdpTransportInfo.Candidate> candidates;
1429 if (includeCandidates) {
1430 candidates = parseCandidates(sessionDescription);
1431 } else {
1432 candidates = ImmutableMultimap.of();
1433 }
1434 this.responderRtpContentMap = respondingRtpContentMap;
1435 storePeerDtlsSetup(respondingRtpContentMap.getDtlsSetup().flip());
1436 final ListenableFuture<RtpContentMap> outgoingContentMapFuture =
1437 prepareOutgoingContentMap(respondingRtpContentMap);
1438 Futures.addCallback(
1439 outgoingContentMapFuture,
1440 new FutureCallback<>() {
1441 @Override
1442 public void onSuccess(final RtpContentMap outgoingContentMap) {
1443 if (includeCandidates) {
1444 Log.d(
1445 Config.LOGTAG,
1446 "including "
1447 + candidates.size()
1448 + " candidates in session accept");
1449 sendSessionAccept(outgoingContentMap.withCandidates(candidates));
1450 } else {
1451 sendSessionAccept(outgoingContentMap);
1452 }
1453 webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
1454 }
1455
1456 @Override
1457 public void onFailure(@NonNull Throwable throwable) {
1458 failureToAcceptSession(throwable);
1459 }
1460 },
1461 MoreExecutors.directExecutor());
1462 }
1463
1464 private void sendSessionAccept(final RtpContentMap rtpContentMap) {
1465 if (isTerminated()) {
1466 Log.w(
1467 Config.LOGTAG,
1468 id.account.getJid().asBareJid()
1469 + ": preparing session accept was too slow. already terminated. nothing to do.");
1470 return;
1471 }
1472 transitionOrThrow(State.SESSION_ACCEPTED);
1473 final JinglePacket sessionAccept =
1474 rtpContentMap.toJinglePacket(JinglePacket.Action.SESSION_ACCEPT, id.sessionId);
1475 send(sessionAccept);
1476 }
1477
1478 private ListenableFuture<RtpContentMap> prepareOutgoingContentMap(
1479 final RtpContentMap rtpContentMap) {
1480 if (this.omemoVerification.hasDeviceId()) {
1481 ListenableFuture<AxolotlService.OmemoVerifiedPayload<OmemoVerifiedRtpContentMap>>
1482 verifiedPayloadFuture =
1483 id.account
1484 .getAxolotlService()
1485 .encrypt(
1486 rtpContentMap,
1487 id.with,
1488 omemoVerification.getDeviceId());
1489 return Futures.transform(
1490 verifiedPayloadFuture,
1491 verifiedPayload -> {
1492 omemoVerification.setOrEnsureEqual(verifiedPayload);
1493 return verifiedPayload.getPayload();
1494 },
1495 MoreExecutors.directExecutor());
1496 } else {
1497 return Futures.immediateFuture(rtpContentMap);
1498 }
1499 }
1500
1501 synchronized void deliveryMessage(
1502 final Jid from,
1503 final Element message,
1504 final String serverMessageId,
1505 final long timestamp) {
1506 Log.d(
1507 Config.LOGTAG,
1508 id.account.getJid().asBareJid()
1509 + ": delivered message to JingleRtpConnection "
1510 + message);
1511 switch (message.getName()) {
1512 case "propose" -> receivePropose(
1513 from, Propose.upgrade(message), serverMessageId, timestamp);
1514 case "proceed" -> receiveProceed(
1515 from, Proceed.upgrade(message), serverMessageId, timestamp);
1516 case "retract" -> receiveRetract(from, serverMessageId, timestamp);
1517 case "reject" -> receiveReject(from, serverMessageId, timestamp);
1518 case "accept" -> receiveAccept(from, serverMessageId, timestamp);
1519 }
1520 }
1521
1522 void deliverFailedProceed(final String message) {
1523 Log.d(
1524 Config.LOGTAG,
1525 id.account.getJid().asBareJid()
1526 + ": receive message error for proceed message ("
1527 + Strings.nullToEmpty(message)
1528 + ")");
1529 if (transition(State.TERMINATED_CONNECTIVITY_ERROR)) {
1530 webRTCWrapper.close();
1531 Log.d(
1532 Config.LOGTAG,
1533 id.account.getJid().asBareJid() + ": transitioned into connectivity error");
1534 this.finish();
1535 }
1536 }
1537
1538 private void receiveAccept(final Jid from, final String serverMsgId, final long timestamp) {
1539 final boolean originatedFromMyself =
1540 from.asBareJid().equals(id.account.getJid().asBareJid());
1541 if (originatedFromMyself) {
1542 if (transition(State.ACCEPTED)) {
1543 acceptedOnOtherDevice(serverMsgId, timestamp);
1544 } else {
1545 Log.d(
1546 Config.LOGTAG,
1547 id.account.getJid().asBareJid()
1548 + ": unable to transition to accept because already in state="
1549 + this.state);
1550 Log.d(Config.LOGTAG, id.account.getJid() + ": received accept from " + from);
1551 }
1552 } else {
1553 Log.d(
1554 Config.LOGTAG,
1555 id.account.getJid().asBareJid() + ": ignoring 'accept' from " + from);
1556 }
1557 }
1558
1559 private void acceptedOnOtherDevice(final String serverMsgId, final long timestamp) {
1560 if (serverMsgId != null) {
1561 this.message.setServerMsgId(serverMsgId);
1562 }
1563 this.message.setTime(timestamp);
1564 this.message.setCarbon(true); // indicate that call was accepted on other device
1565 this.writeLogMessageSuccess(0);
1566 this.xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
1567 this.finish();
1568 }
1569
1570 private void receiveReject(final Jid from, final String serverMsgId, final long timestamp) {
1571 final boolean originatedFromMyself =
1572 from.asBareJid().equals(id.account.getJid().asBareJid());
1573 // reject from another one of my clients
1574 if (originatedFromMyself) {
1575 receiveRejectFromMyself(serverMsgId, timestamp);
1576 } else if (isInitiator()) {
1577 if (from.equals(id.with)) {
1578 receiveRejectFromResponder();
1579 } else {
1580 Log.d(
1581 Config.LOGTAG,
1582 id.account.getJid()
1583 + ": ignoring reject from "
1584 + from
1585 + " for session with "
1586 + id.with);
1587 }
1588 } else {
1589 Log.d(
1590 Config.LOGTAG,
1591 id.account.getJid()
1592 + ": ignoring reject from "
1593 + from
1594 + " for session with "
1595 + id.with);
1596 }
1597 }
1598
1599 private void receiveRejectFromMyself(String serverMsgId, long timestamp) {
1600 if (transition(State.REJECTED)) {
1601 this.xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
1602 this.finish();
1603 if (serverMsgId != null) {
1604 this.message.setServerMsgId(serverMsgId);
1605 }
1606 this.message.setTime(timestamp);
1607 this.message.setCarbon(true); // indicate that call was rejected on other device
1608 writeLogMessageMissed();
1609 } else {
1610 Log.d(
1611 Config.LOGTAG,
1612 "not able to transition into REJECTED because already in " + this.state);
1613 }
1614 }
1615
1616 private void receiveRejectFromResponder() {
1617 if (isInState(State.PROCEED)) {
1618 Log.d(
1619 Config.LOGTAG,
1620 id.account.getJid()
1621 + ": received reject while still in proceed. callee reconsidered");
1622 closeTransitionLogFinish(State.REJECTED_RACED);
1623 return;
1624 }
1625 if (isInState(State.SESSION_INITIALIZED_PRE_APPROVED)) {
1626 Log.d(
1627 Config.LOGTAG,
1628 id.account.getJid()
1629 + ": received reject while in SESSION_INITIATED_PRE_APPROVED. callee reconsidered before receiving session-init");
1630 closeTransitionLogFinish(State.TERMINATED_DECLINED_OR_BUSY);
1631 return;
1632 }
1633 Log.d(
1634 Config.LOGTAG,
1635 id.account.getJid()
1636 + ": ignoring reject from responder because already in state "
1637 + this.state);
1638 }
1639
1640 private void receivePropose(
1641 final Jid from, final Propose propose, final String serverMsgId, final long timestamp) {
1642 final boolean originatedFromMyself =
1643 from.asBareJid().equals(id.account.getJid().asBareJid());
1644 if (originatedFromMyself) {
1645 Log.d(
1646 Config.LOGTAG,
1647 id.account.getJid().asBareJid() + ": saw proposal from myself. ignoring");
1648 } else if (transition(
1649 State.PROPOSED,
1650 () -> {
1651 final Collection<RtpDescription> descriptions =
1652 Collections2.transform(
1653 Collections2.filter(
1654 propose.getDescriptions(),
1655 d -> d instanceof RtpDescription),
1656 input -> (RtpDescription) input);
1657 final Collection<Media> media =
1658 Collections2.transform(descriptions, RtpDescription::getMedia);
1659 Preconditions.checkState(
1660 !media.contains(Media.UNKNOWN),
1661 "RTP descriptions contain unknown media");
1662 Log.d(
1663 Config.LOGTAG,
1664 id.account.getJid().asBareJid()
1665 + ": received session proposal from "
1666 + from
1667 + " for "
1668 + media);
1669 this.setProposedMedia(Sets.newHashSet(media));
1670 })) {
1671 if (serverMsgId != null) {
1672 this.message.setServerMsgId(serverMsgId);
1673 }
1674 this.message.setTime(timestamp);
1675 startRinging();
1676 if (xmppConnectionService.confirmMessages() && id.getContact().showInContactList()) {
1677 sendJingleMessage("ringing");
1678 }
1679 } else {
1680 Log.d(
1681 Config.LOGTAG,
1682 id.account.getJid()
1683 + ": ignoring session proposal because already in "
1684 + state);
1685 }
1686 }
1687
1688 private void startRinging() {
1689 this.callIntegration.setRinging();
1690 Log.d(
1691 Config.LOGTAG,
1692 id.account.getJid().asBareJid()
1693 + ": received call from "
1694 + id.with
1695 + ". start ringing");
1696 ringingTimeoutFuture =
1697 jingleConnectionManager.schedule(
1698 this::ringingTimeout, BUSY_TIME_OUT, TimeUnit.SECONDS);
1699 if (CallIntegration.selfManaged(xmppConnectionService)) {
1700 return;
1701 }
1702 xmppConnectionService.getNotificationService().startRinging(id, getMedia());
1703 }
1704
1705 private synchronized void ringingTimeout() {
1706 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": timeout reached for ringing");
1707 switch (this.state) {
1708 case PROPOSED -> {
1709 message.markUnread();
1710 rejectCallFromProposed();
1711 }
1712 case SESSION_INITIALIZED -> {
1713 message.markUnread();
1714 rejectCallFromSessionInitiate();
1715 }
1716 }
1717 xmppConnectionService.getNotificationService().pushMissedCallNow(message);
1718 }
1719
1720 private void cancelRingingTimeout() {
1721 final ScheduledFuture<?> future = this.ringingTimeoutFuture;
1722 if (future != null && !future.isCancelled()) {
1723 future.cancel(false);
1724 }
1725 }
1726
1727 private void receiveProceed(
1728 final Jid from, final Proceed proceed, final String serverMsgId, final long timestamp) {
1729 final Set<Media> media =
1730 Preconditions.checkNotNull(
1731 this.proposedMedia, "Proposed media has to be set before handling proceed");
1732 Preconditions.checkState(media.size() > 0, "Proposed media should not be empty");
1733 if (from.equals(id.with)) {
1734 if (isInitiator()) {
1735 if (transition(State.PROCEED)) {
1736 if (serverMsgId != null) {
1737 this.message.setServerMsgId(serverMsgId);
1738 }
1739 this.message.setTime(timestamp);
1740 final Integer remoteDeviceId = proceed.getDeviceId();
1741 if (isOmemoEnabled()) {
1742 this.omemoVerification.setDeviceId(remoteDeviceId);
1743 } else {
1744 if (remoteDeviceId != null) {
1745 Log.d(
1746 Config.LOGTAG,
1747 id.account.getJid().asBareJid()
1748 + ": remote party signaled support for OMEMO verification but we have OMEMO disabled");
1749 }
1750 this.omemoVerification.setDeviceId(null);
1751 }
1752 this.sendSessionInitiate(media, State.SESSION_INITIALIZED_PRE_APPROVED);
1753 } else {
1754 Log.d(
1755 Config.LOGTAG,
1756 String.format(
1757 "%s: ignoring proceed because already in %s",
1758 id.account.getJid().asBareJid(), this.state));
1759 }
1760 } else {
1761 Log.d(
1762 Config.LOGTAG,
1763 String.format(
1764 "%s: ignoring proceed because we were not initializing",
1765 id.account.getJid().asBareJid()));
1766 }
1767 } else if (from.asBareJid().equals(id.account.getJid().asBareJid())) {
1768 if (transition(State.ACCEPTED)) {
1769 Log.d(
1770 Config.LOGTAG,
1771 id.account.getJid().asBareJid()
1772 + ": moved session with "
1773 + id.with
1774 + " into state accepted after received carbon copied proceed");
1775 acceptedOnOtherDevice(serverMsgId, timestamp);
1776 }
1777 } else {
1778 Log.d(
1779 Config.LOGTAG,
1780 String.format(
1781 "%s: ignoring proceed from %s. was expected from %s",
1782 id.account.getJid().asBareJid(), from, id.with));
1783 }
1784 }
1785
1786 private void receiveRetract(final Jid from, final String serverMsgId, final long timestamp) {
1787 if (from.equals(id.with)) {
1788 final State target =
1789 this.state == State.PROCEED ? State.RETRACTED_RACED : State.RETRACTED;
1790 if (transition(target)) {
1791 xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
1792 xmppConnectionService.getNotificationService().pushMissedCallNow(message);
1793 Log.d(
1794 Config.LOGTAG,
1795 id.account.getJid().asBareJid()
1796 + ": session with "
1797 + id.with
1798 + " has been retracted (serverMsgId="
1799 + serverMsgId
1800 + ")");
1801 if (serverMsgId != null) {
1802 this.message.setServerMsgId(serverMsgId);
1803 }
1804 this.message.setTime(timestamp);
1805 if (target == State.RETRACTED) {
1806 this.message.markUnread();
1807 }
1808 writeLogMessageMissed();
1809 finish();
1810 } else {
1811 Log.d(Config.LOGTAG, "ignoring retract because already in " + this.state);
1812 }
1813 } else {
1814 // TODO parse retract from self
1815 Log.d(
1816 Config.LOGTAG,
1817 id.account.getJid().asBareJid()
1818 + ": received retract from "
1819 + from
1820 + ". expected retract from"
1821 + id.with
1822 + ". ignoring");
1823 }
1824 }
1825
1826 public void sendSessionInitiate() {
1827 sendSessionInitiate(this.proposedMedia, State.SESSION_INITIALIZED);
1828 }
1829
1830 private void sendSessionInitiate(final Set<Media> media, final State targetState) {
1831 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": prepare session-initiate");
1832 discoverIceServers(iceServers -> sendSessionInitiate(media, targetState, iceServers));
1833 }
1834
1835 private synchronized void sendSessionInitiate(
1836 final Set<Media> media,
1837 final State targetState,
1838 final List<PeerConnection.IceServer> iceServers) {
1839 if (isTerminated()) {
1840 Log.w(
1841 Config.LOGTAG,
1842 id.account.getJid().asBareJid()
1843 + ": ICE servers got discovered when session was already terminated. nothing to do.");
1844 return;
1845 }
1846 final boolean includeCandidates = remoteHasSdpOfferAnswer();
1847 try {
1848 setupWebRTC(media, iceServers, !includeCandidates);
1849 } catch (final WebRTCWrapper.InitializationException e) {
1850 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to initialize WebRTC");
1851 webRTCWrapper.close();
1852 sendRetract(Reason.ofThrowable(e));
1853 return;
1854 }
1855 try {
1856 org.webrtc.SessionDescription webRTCSessionDescription =
1857 this.webRTCWrapper.setLocalDescription(includeCandidates).get();
1858 prepareSessionInitiate(webRTCSessionDescription, includeCandidates, targetState);
1859 } catch (final Exception e) {
1860 // TODO sending the error text is worthwhile as well. Especially for FailureToSet
1861 // exceptions
1862 failureToInitiateSession(e, targetState);
1863 }
1864 }
1865
1866 private void failureToInitiateSession(final Throwable throwable, final State targetState) {
1867 if (isTerminated()) {
1868 return;
1869 }
1870 Log.d(
1871 Config.LOGTAG,
1872 id.account.getJid().asBareJid() + ": unable to sendSessionInitiate",
1873 Throwables.getRootCause(throwable));
1874 webRTCWrapper.close();
1875 final Reason reason = Reason.ofThrowable(throwable);
1876 if (isInState(targetState)) {
1877 sendSessionTerminate(reason, throwable.getMessage());
1878 } else {
1879 sendRetract(reason);
1880 }
1881 }
1882
1883 private void sendRetract(final Reason reason) {
1884 // TODO embed reason into retract
1885 sendJingleMessage("retract", id.with.asBareJid());
1886 transitionOrThrow(reasonToState(reason));
1887 this.finish();
1888 }
1889
1890 private void prepareSessionInitiate(
1891 final org.webrtc.SessionDescription webRTCSessionDescription,
1892 final boolean includeCandidates,
1893 final State targetState) {
1894 final SessionDescription sessionDescription =
1895 SessionDescription.parse(webRTCSessionDescription.description);
1896 final RtpContentMap rtpContentMap = RtpContentMap.of(sessionDescription, true);
1897 final ImmutableMultimap<String, IceUdpTransportInfo.Candidate> candidates;
1898 if (includeCandidates) {
1899 candidates = parseCandidates(sessionDescription);
1900 } else {
1901 candidates = ImmutableMultimap.of();
1902 }
1903 this.initiatorRtpContentMap = rtpContentMap;
1904 final ListenableFuture<RtpContentMap> outgoingContentMapFuture =
1905 encryptSessionInitiate(rtpContentMap);
1906 Futures.addCallback(
1907 outgoingContentMapFuture,
1908 new FutureCallback<>() {
1909 @Override
1910 public void onSuccess(final RtpContentMap outgoingContentMap) {
1911 if (includeCandidates) {
1912 Log.d(
1913 Config.LOGTAG,
1914 "including "
1915 + candidates.size()
1916 + " candidates in session initiate");
1917 sendSessionInitiate(
1918 outgoingContentMap.withCandidates(candidates), targetState);
1919 } else {
1920 sendSessionInitiate(outgoingContentMap, targetState);
1921 }
1922 webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
1923 }
1924
1925 @Override
1926 public void onFailure(@NonNull final Throwable throwable) {
1927 failureToInitiateSession(throwable, targetState);
1928 }
1929 },
1930 MoreExecutors.directExecutor());
1931 }
1932
1933 private void sendSessionInitiate(final RtpContentMap rtpContentMap, final State targetState) {
1934 if (isTerminated()) {
1935 Log.w(
1936 Config.LOGTAG,
1937 id.account.getJid().asBareJid()
1938 + ": preparing session was too slow. already terminated. nothing to do.");
1939 return;
1940 }
1941 this.transitionOrThrow(targetState);
1942 final JinglePacket sessionInitiate =
1943 rtpContentMap.toJinglePacket(JinglePacket.Action.SESSION_INITIATE, id.sessionId);
1944 send(sessionInitiate);
1945 }
1946
1947 private ListenableFuture<RtpContentMap> encryptSessionInitiate(
1948 final RtpContentMap rtpContentMap) {
1949 if (this.omemoVerification.hasDeviceId()) {
1950 final ListenableFuture<AxolotlService.OmemoVerifiedPayload<OmemoVerifiedRtpContentMap>>
1951 verifiedPayloadFuture =
1952 id.account
1953 .getAxolotlService()
1954 .encrypt(
1955 rtpContentMap,
1956 id.with,
1957 omemoVerification.getDeviceId());
1958 final ListenableFuture<RtpContentMap> future =
1959 Futures.transform(
1960 verifiedPayloadFuture,
1961 verifiedPayload -> {
1962 omemoVerification.setSessionFingerprint(
1963 verifiedPayload.getFingerprint());
1964 return verifiedPayload.getPayload();
1965 },
1966 MoreExecutors.directExecutor());
1967 if (Config.REQUIRE_RTP_VERIFICATION) {
1968 return future;
1969 }
1970 return Futures.catching(
1971 future,
1972 CryptoFailedException.class,
1973 e -> {
1974 Log.w(
1975 Config.LOGTAG,
1976 id.account.getJid().asBareJid()
1977 + ": unable to use OMEMO DTLS verification on outgoing session initiate. falling back",
1978 e);
1979 return rtpContentMap;
1980 },
1981 MoreExecutors.directExecutor());
1982 } else {
1983 return Futures.immediateFuture(rtpContentMap);
1984 }
1985 }
1986
1987 protected void sendSessionTerminate(final Reason reason) {
1988 sendSessionTerminate(reason, null);
1989 }
1990
1991 protected void sendSessionTerminate(final Reason reason, final String text) {
1992 sendSessionTerminate(reason, text, this::writeLogMessage);
1993 sendJingleMessageFinish(reason);
1994 }
1995
1996 private void sendTransportInfo(
1997 final String contentName, IceUdpTransportInfo.Candidate candidate) {
1998 final RtpContentMap transportInfo;
1999 try {
2000 final RtpContentMap rtpContentMap =
2001 isInitiator() ? this.initiatorRtpContentMap : this.responderRtpContentMap;
2002 transportInfo = rtpContentMap.transportInfo(contentName, candidate);
2003 } catch (final Exception e) {
2004 Log.d(
2005 Config.LOGTAG,
2006 id.account.getJid().asBareJid()
2007 + ": unable to prepare transport-info from candidate for content="
2008 + contentName);
2009 return;
2010 }
2011 final JinglePacket jinglePacket =
2012 transportInfo.toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
2013 send(jinglePacket);
2014 }
2015
2016 public RtpEndUserState getEndUserState() {
2017 switch (this.state) {
2018 case NULL, PROPOSED, SESSION_INITIALIZED -> {
2019 if (isInitiator()) {
2020 return RtpEndUserState.RINGING;
2021 } else {
2022 return RtpEndUserState.INCOMING_CALL;
2023 }
2024 }
2025 case PROCEED -> {
2026 if (isInitiator()) {
2027 return RtpEndUserState.RINGING;
2028 } else {
2029 return RtpEndUserState.ACCEPTING_CALL;
2030 }
2031 }
2032 case SESSION_INITIALIZED_PRE_APPROVED -> {
2033 if (isInitiator()) {
2034 return RtpEndUserState.RINGING;
2035 } else {
2036 return RtpEndUserState.CONNECTING;
2037 }
2038 }
2039 case SESSION_ACCEPTED -> {
2040 final ContentAddition ca = getPendingContentAddition();
2041 if (ca != null && ca.direction == ContentAddition.Direction.INCOMING) {
2042 return RtpEndUserState.INCOMING_CONTENT_ADD;
2043 }
2044 return getPeerConnectionStateAsEndUserState();
2045 }
2046 case REJECTED, REJECTED_RACED, TERMINATED_DECLINED_OR_BUSY -> {
2047 if (isInitiator()) {
2048 return RtpEndUserState.DECLINED_OR_BUSY;
2049 } else {
2050 return RtpEndUserState.ENDED;
2051 }
2052 }
2053 case TERMINATED_SUCCESS, ACCEPTED, RETRACTED, TERMINATED_CANCEL_OR_TIMEOUT -> {
2054 return RtpEndUserState.ENDED;
2055 }
2056 case RETRACTED_RACED -> {
2057 if (isInitiator()) {
2058 return RtpEndUserState.ENDED;
2059 } else {
2060 return RtpEndUserState.RETRACTED;
2061 }
2062 }
2063 case TERMINATED_CONNECTIVITY_ERROR -> {
2064 return zeroDuration()
2065 ? RtpEndUserState.CONNECTIVITY_ERROR
2066 : RtpEndUserState.CONNECTIVITY_LOST_ERROR;
2067 }
2068 case TERMINATED_APPLICATION_FAILURE -> {
2069 return RtpEndUserState.APPLICATION_ERROR;
2070 }
2071 case TERMINATED_SECURITY_ERROR -> {
2072 return RtpEndUserState.SECURITY_ERROR;
2073 }
2074 }
2075 throw new IllegalStateException(
2076 String.format("%s has no equivalent EndUserState", this.state));
2077 }
2078
2079 private RtpEndUserState getPeerConnectionStateAsEndUserState() {
2080 final PeerConnection.PeerConnectionState state;
2081 try {
2082 state = webRTCWrapper.getState();
2083 } catch (final WebRTCWrapper.PeerConnectionNotInitialized e) {
2084 // We usually close the WebRTCWrapper *before* transitioning so we might still
2085 // be in SESSION_ACCEPTED even though the peerConnection has been torn down
2086 return RtpEndUserState.ENDING_CALL;
2087 }
2088 return switch (state) {
2089 case CONNECTED -> RtpEndUserState.CONNECTED;
2090 case NEW, CONNECTING -> RtpEndUserState.CONNECTING;
2091 case CLOSED -> RtpEndUserState.ENDING_CALL;
2092 default -> zeroDuration()
2093 ? RtpEndUserState.CONNECTIVITY_ERROR
2094 : RtpEndUserState.RECONNECTING;
2095 };
2096 }
2097
2098 private boolean isPeerConnectionConnected() {
2099 try {
2100 return webRTCWrapper.getState() == PeerConnection.PeerConnectionState.CONNECTED;
2101 } catch (final WebRTCWrapper.PeerConnectionNotInitialized e) {
2102 return false;
2103 }
2104 }
2105
2106 private void updateCallIntegrationState() {
2107 switch (this.state) {
2108 case NULL, PROPOSED, SESSION_INITIALIZED -> {
2109 if (isInitiator()) {
2110 this.callIntegration.setDialing();
2111 } else {
2112 this.callIntegration.setRinging();
2113 }
2114 }
2115 case PROCEED, SESSION_INITIALIZED_PRE_APPROVED -> {
2116 if (isInitiator()) {
2117 this.callIntegration.setDialing();
2118 } else {
2119 this.callIntegration.setInitialized();
2120 }
2121 }
2122 case SESSION_ACCEPTED -> {
2123 if (isPeerConnectionConnected()) {
2124 this.callIntegration.setActive();
2125 } else {
2126 this.callIntegration.setInitialized();
2127 }
2128 }
2129 case REJECTED, REJECTED_RACED, TERMINATED_DECLINED_OR_BUSY -> {
2130 if (isInitiator()) {
2131 this.callIntegration.busy();
2132 } else {
2133 this.callIntegration.rejected();
2134 }
2135 }
2136 case TERMINATED_SUCCESS -> this.callIntegration.success();
2137 case ACCEPTED -> this.callIntegration.accepted();
2138 case RETRACTED, RETRACTED_RACED, TERMINATED_CANCEL_OR_TIMEOUT -> this.callIntegration
2139 .retracted();
2140 case TERMINATED_CONNECTIVITY_ERROR,
2141 TERMINATED_APPLICATION_FAILURE,
2142 TERMINATED_SECURITY_ERROR -> this.callIntegration.error();
2143 default -> throw new IllegalStateException(
2144 String.format("%s is not handled", this.state));
2145 }
2146 }
2147
2148 public ContentAddition getPendingContentAddition() {
2149 final RtpContentMap in = this.incomingContentAdd;
2150 final RtpContentMap out = this.outgoingContentAdd;
2151 if (out != null) {
2152 return ContentAddition.of(ContentAddition.Direction.OUTGOING, out);
2153 } else if (in != null) {
2154 return ContentAddition.of(ContentAddition.Direction.INCOMING, in);
2155 } else {
2156 return null;
2157 }
2158 }
2159
2160 public Set<Media> getMedia() {
2161 final State current = getState();
2162 if (current == State.NULL) {
2163 if (isInitiator()) {
2164 return Preconditions.checkNotNull(
2165 this.proposedMedia, "RTP connection has not been initialized properly");
2166 }
2167 throw new IllegalStateException("RTP connection has not been initialized yet");
2168 }
2169 if (Arrays.asList(State.PROPOSED, State.PROCEED).contains(current)) {
2170 return Preconditions.checkNotNull(
2171 this.proposedMedia, "RTP connection has not been initialized properly");
2172 }
2173 final RtpContentMap localContentMap = getLocalContentMap();
2174 final RtpContentMap initiatorContentMap = initiatorRtpContentMap;
2175 if (localContentMap != null) {
2176 return localContentMap.getMedia();
2177 } else if (initiatorContentMap != null) {
2178 return initiatorContentMap.getMedia();
2179 } else if (isTerminated()) {
2180 return Collections.emptySet(); // we might fail before we ever got a chance to set media
2181 } else {
2182 return Preconditions.checkNotNull(
2183 this.proposedMedia, "RTP connection has not been initialized properly");
2184 }
2185 }
2186
2187 public boolean isVerified() {
2188 final String fingerprint = this.omemoVerification.getFingerprint();
2189 if (fingerprint == null) {
2190 return false;
2191 }
2192 final FingerprintStatus status =
2193 id.account.getAxolotlService().getFingerprintTrust(fingerprint);
2194 return status != null && status.isVerified();
2195 }
2196
2197 public boolean addMedia(final Media media) {
2198 final Set<Media> currentMedia = getMedia();
2199 if (currentMedia.contains(media)) {
2200 throw new IllegalStateException(String.format("%s has already been proposed", media));
2201 }
2202 // TODO add state protection - can only add while ACCEPTED or so
2203 Log.d(Config.LOGTAG, "adding media: " + media);
2204 return webRTCWrapper.addTrack(media);
2205 }
2206
2207 public synchronized void acceptCall() {
2208 switch (this.state) {
2209 case PROPOSED -> {
2210 cancelRingingTimeout();
2211 acceptCallFromProposed();
2212 }
2213 case SESSION_INITIALIZED -> {
2214 cancelRingingTimeout();
2215 acceptCallFromSessionInitialized();
2216 }
2217 case ACCEPTED -> Log.w(
2218 Config.LOGTAG,
2219 id.account.getJid().asBareJid()
2220 + ": the call has already been accepted with another client. UI was just lagging behind");
2221 case PROCEED, SESSION_ACCEPTED -> Log.w(
2222 Config.LOGTAG,
2223 id.account.getJid().asBareJid()
2224 + ": the call has already been accepted. user probably double tapped the UI");
2225 default -> throw new IllegalStateException("Can not accept call from " + this.state);
2226 }
2227 }
2228
2229 public synchronized void rejectCall() {
2230 if (isTerminated()) {
2231 Log.w(
2232 Config.LOGTAG,
2233 id.account.getJid().asBareJid()
2234 + ": received rejectCall() when session has already been terminated. nothing to do");
2235 return;
2236 }
2237 switch (this.state) {
2238 case PROPOSED -> rejectCallFromProposed();
2239 case SESSION_INITIALIZED -> rejectCallFromSessionInitiate();
2240 default -> throw new IllegalStateException("Can not reject call from " + this.state);
2241 }
2242 }
2243
2244 public synchronized void integrationFailure() {
2245 final var state = getState();
2246 if (state == State.PROPOSED) {
2247 Log.e(
2248 Config.LOGTAG,
2249 id.account.getJid().asBareJid()
2250 + ": failed call integration in state proposed");
2251 rejectCallFromProposed();
2252 } else if (state == State.SESSION_INITIALIZED) {
2253 Log.e(Config.LOGTAG, id.account.getJid().asBareJid() + ": failed call integration");
2254 this.webRTCWrapper.close();
2255 sendSessionTerminate(Reason.FAILED_APPLICATION, "CallIntegration failed");
2256 } else {
2257 throw new IllegalStateException(
2258 String.format("Can not fail integration in state %s", state));
2259 }
2260 }
2261
2262 public synchronized void endCall() {
2263 if (isTerminated()) {
2264 Log.w(
2265 Config.LOGTAG,
2266 id.account.getJid().asBareJid()
2267 + ": received endCall() when session has already been terminated. nothing to do");
2268 return;
2269 }
2270 if (isInState(State.PROPOSED) && isResponder()) {
2271 rejectCallFromProposed();
2272 return;
2273 }
2274 if (isInState(State.PROCEED)) {
2275 if (isInitiator()) {
2276 retractFromProceed();
2277 } else {
2278 rejectCallFromProceed();
2279 }
2280 return;
2281 }
2282 if (isInitiator()
2283 && isInState(State.SESSION_INITIALIZED, State.SESSION_INITIALIZED_PRE_APPROVED)) {
2284 this.webRTCWrapper.close();
2285 sendSessionTerminate(Reason.CANCEL);
2286 return;
2287 }
2288 if (isInState(State.SESSION_INITIALIZED)) {
2289 rejectCallFromSessionInitiate();
2290 return;
2291 }
2292 if (isInState(State.SESSION_INITIALIZED_PRE_APPROVED, State.SESSION_ACCEPTED)) {
2293 this.webRTCWrapper.close();
2294 sendSessionTerminate(Reason.SUCCESS);
2295 return;
2296 }
2297 if (isInState(
2298 State.TERMINATED_APPLICATION_FAILURE,
2299 State.TERMINATED_CONNECTIVITY_ERROR,
2300 State.TERMINATED_DECLINED_OR_BUSY)) {
2301 Log.d(
2302 Config.LOGTAG,
2303 "ignoring request to end call because already in state " + this.state);
2304 return;
2305 }
2306 throw new IllegalStateException(
2307 "called 'endCall' while in state " + this.state + ". isInitiator=" + isInitiator());
2308 }
2309
2310 private void retractFromProceed() {
2311 Log.d(Config.LOGTAG, "retract from proceed");
2312 this.sendJingleMessage("retract");
2313 closeTransitionLogFinish(State.RETRACTED_RACED);
2314 }
2315
2316 private void closeTransitionLogFinish(final State state) {
2317 this.webRTCWrapper.close();
2318 transitionOrThrow(state);
2319 writeLogMessage(state);
2320 finish();
2321 }
2322
2323 private void setupWebRTC(
2324 final Set<Media> media,
2325 final List<PeerConnection.IceServer> iceServers,
2326 final boolean trickle)
2327 throws WebRTCWrapper.InitializationException {
2328 this.jingleConnectionManager.ensureConnectionIsRegistered(this);
2329 this.webRTCWrapper.setup(this.xmppConnectionService);
2330 this.webRTCWrapper.initializePeerConnection(media, iceServers, trickle);
2331 this.webRTCWrapper.setMicrophoneEnabledOrThrow(callIntegration.isMicrophoneEnabled());
2332 }
2333
2334 private void acceptCallFromProposed() {
2335 transitionOrThrow(State.PROCEED);
2336 xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
2337 this.callIntegration.startAudioRouting();
2338 this.sendJingleMessage("accept", id.account.getJid().asBareJid());
2339 this.sendJingleMessage("proceed");
2340 }
2341
2342 private void rejectCallFromProposed() {
2343 transitionOrThrow(State.REJECTED);
2344 writeLogMessageMissed();
2345 xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
2346 this.sendJingleMessage("reject");
2347 finish();
2348 }
2349
2350 private void rejectCallFromProceed() {
2351 this.sendJingleMessage("reject");
2352 closeTransitionLogFinish(State.REJECTED_RACED);
2353 }
2354
2355 private void rejectCallFromSessionInitiate() {
2356 webRTCWrapper.close();
2357 sendSessionTerminate(Reason.DECLINE);
2358 xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
2359 }
2360
2361 private void sendJingleMessage(final String action) {
2362 sendJingleMessage(action, id.with);
2363 }
2364
2365 private void sendJingleMessage(final String action, final Jid to) {
2366 final MessagePacket messagePacket = new MessagePacket();
2367 messagePacket.setType(MessagePacket.TYPE_CHAT); // we want to carbon copy those
2368 messagePacket.setTo(to);
2369 final Element intent =
2370 messagePacket
2371 .addChild(action, Namespace.JINGLE_MESSAGE)
2372 .setAttribute("id", id.sessionId);
2373 if ("proceed".equals(action)) {
2374 messagePacket.setId(JINGLE_MESSAGE_PROCEED_ID_PREFIX + id.sessionId);
2375 if (isOmemoEnabled()) {
2376 final int deviceId = id.account.getAxolotlService().getOwnDeviceId();
2377 final Element device =
2378 intent.addChild("device", Namespace.OMEMO_DTLS_SRTP_VERIFICATION);
2379 device.setAttribute("id", deviceId);
2380 }
2381 }
2382 messagePacket.addChild("store", "urn:xmpp:hints");
2383 xmppConnectionService.sendMessagePacket(id.account, messagePacket);
2384 }
2385
2386 private void sendJingleMessageFinish(final Reason reason) {
2387 final var account = id.getAccount();
2388 final MessagePacket messagePacket =
2389 xmppConnectionService
2390 .getMessageGenerator()
2391 .sessionFinish(id.with, id.sessionId, reason);
2392 xmppConnectionService.sendMessagePacket(account, messagePacket);
2393 }
2394
2395 private boolean isOmemoEnabled() {
2396 final Conversational conversational = message.getConversation();
2397 if (conversational instanceof Conversation) {
2398 return ((Conversation) conversational).getNextEncryption()
2399 == Message.ENCRYPTION_AXOLOTL;
2400 }
2401 return false;
2402 }
2403
2404 private void acceptCallFromSessionInitialized() {
2405 xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
2406 this.callIntegration.startAudioRouting();
2407 sendSessionAccept();
2408 }
2409
2410 @Override
2411 protected synchronized boolean transition(final State target, final Runnable runnable) {
2412 if (super.transition(target, runnable)) {
2413 updateEndUserState();
2414 updateOngoingCallNotification();
2415 return true;
2416 } else {
2417 return false;
2418 }
2419 }
2420
2421 @Override
2422 public void onIceCandidate(final IceCandidate iceCandidate) {
2423 final RtpContentMap rtpContentMap =
2424 isInitiator() ? this.initiatorRtpContentMap : this.responderRtpContentMap;
2425 final IceUdpTransportInfo.Credentials credentials;
2426 try {
2427 credentials = rtpContentMap.getCredentials(iceCandidate.sdpMid);
2428 } catch (final IllegalArgumentException e) {
2429 Log.d(Config.LOGTAG, "ignoring (not sending) candidate: " + iceCandidate, e);
2430 return;
2431 }
2432 final String uFrag = credentials.ufrag;
2433 final IceUdpTransportInfo.Candidate candidate =
2434 IceUdpTransportInfo.Candidate.fromSdpAttribute(iceCandidate.sdp, uFrag);
2435 if (candidate == null) {
2436 Log.d(Config.LOGTAG, "ignoring (not sending) candidate: " + iceCandidate);
2437 return;
2438 }
2439 Log.d(Config.LOGTAG, "sending candidate: " + iceCandidate);
2440 sendTransportInfo(iceCandidate.sdpMid, candidate);
2441 }
2442
2443 @Override
2444 public void onConnectionChange(final PeerConnection.PeerConnectionState newState) {
2445 Log.d(
2446 Config.LOGTAG,
2447 id.account.getJid().asBareJid() + ": PeerConnectionState changed to " + newState);
2448 this.stateHistory.add(newState);
2449 if (newState == PeerConnection.PeerConnectionState.CONNECTED) {
2450 this.sessionDuration.start();
2451 updateOngoingCallNotification();
2452 } else if (this.sessionDuration.isRunning()) {
2453 this.sessionDuration.stop();
2454 updateOngoingCallNotification();
2455 }
2456
2457 final boolean neverConnected =
2458 !this.stateHistory.contains(PeerConnection.PeerConnectionState.CONNECTED);
2459
2460 if (newState == PeerConnection.PeerConnectionState.FAILED) {
2461 if (neverConnected) {
2462 if (isTerminated()) {
2463 Log.d(
2464 Config.LOGTAG,
2465 id.account.getJid().asBareJid()
2466 + ": not sending session-terminate after connectivity error because session is already in state "
2467 + this.state);
2468 return;
2469 }
2470 webRTCWrapper.execute(this::closeWebRTCSessionAfterFailedConnection);
2471 return;
2472 } else {
2473 this.restartIce();
2474 }
2475 }
2476 updateEndUserState();
2477 }
2478
2479 private void restartIce() {
2480 this.stateHistory.clear();
2481 this.webRTCWrapper.restartIceAsync();
2482 }
2483
2484 @Override
2485 public void onRenegotiationNeeded() {
2486 this.webRTCWrapper.execute(this::renegotiate);
2487 }
2488
2489 private synchronized void renegotiate() {
2490 final SessionDescription sessionDescription;
2491 try {
2492 sessionDescription = setLocalSessionDescription();
2493 } catch (final Exception e) {
2494 final Throwable cause = Throwables.getRootCause(e);
2495 webRTCWrapper.close();
2496 if (isTerminated()) {
2497 Log.d(
2498 Config.LOGTAG,
2499 "failed to renegotiate. session was already terminated",
2500 cause);
2501 return;
2502 }
2503 Log.d(Config.LOGTAG, "failed to renegotiate. sending session-terminate", cause);
2504 sendSessionTerminate(Reason.FAILED_APPLICATION, cause.getMessage());
2505 return;
2506 }
2507 final RtpContentMap rtpContentMap = RtpContentMap.of(sessionDescription, isInitiator());
2508 final RtpContentMap currentContentMap = getLocalContentMap();
2509 final boolean iceRestart = currentContentMap.iceRestart(rtpContentMap);
2510 final RtpContentMap.Diff diff = currentContentMap.diff(rtpContentMap);
2511
2512 Log.d(
2513 Config.LOGTAG,
2514 id.getAccount().getJid().asBareJid()
2515 + ": renegotiate. iceRestart="
2516 + iceRestart
2517 + " content id diff="
2518 + diff);
2519
2520 if (diff.hasModifications() && iceRestart) {
2521 webRTCWrapper.close();
2522 sendSessionTerminate(
2523 Reason.FAILED_APPLICATION,
2524 "WebRTC unexpectedly tried to modify content and transport at once");
2525 return;
2526 }
2527
2528 if (iceRestart) {
2529 initiateIceRestart(rtpContentMap);
2530 return;
2531 } else if (diff.isEmpty()) {
2532 Log.d(
2533 Config.LOGTAG,
2534 "renegotiation. nothing to do. SignalingState="
2535 + this.webRTCWrapper.getSignalingState());
2536 }
2537
2538 if (diff.added.isEmpty()) {
2539 return;
2540 }
2541 modifyLocalContentMap(rtpContentMap);
2542 sendContentAdd(rtpContentMap, diff.added);
2543 }
2544
2545 private void initiateIceRestart(final RtpContentMap rtpContentMap) {
2546 final RtpContentMap transportInfo = rtpContentMap.transportInfo();
2547 final JinglePacket jinglePacket =
2548 transportInfo.toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
2549 Log.d(Config.LOGTAG, "initiating ice restart: " + jinglePacket);
2550 jinglePacket.setTo(id.with);
2551 xmppConnectionService.sendIqPacket(
2552 id.account,
2553 jinglePacket,
2554 (account, response) -> {
2555 if (response.getType() == IqPacket.TYPE.RESULT) {
2556 Log.d(Config.LOGTAG, "received success to our ice restart");
2557 setLocalContentMap(rtpContentMap);
2558 webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
2559 return;
2560 }
2561 if (response.getType() == IqPacket.TYPE.ERROR) {
2562 if (isTieBreak(response)) {
2563 Log.d(Config.LOGTAG, "received tie-break as result of ice restart");
2564 return;
2565 }
2566 handleIqErrorResponse(response);
2567 }
2568 if (response.getType() == IqPacket.TYPE.TIMEOUT) {
2569 handleIqTimeoutResponse(response);
2570 }
2571 });
2572 }
2573
2574 private boolean isTieBreak(final IqPacket response) {
2575 final Element error = response.findChild("error");
2576 return error != null && error.hasChild("tie-break", Namespace.JINGLE_ERRORS);
2577 }
2578
2579 private void sendContentAdd(final RtpContentMap rtpContentMap, final Collection<String> added) {
2580 final RtpContentMap contentAdd = rtpContentMap.toContentModification(added);
2581 this.outgoingContentAdd = contentAdd;
2582 final ListenableFuture<RtpContentMap> outgoingContentMapFuture =
2583 prepareOutgoingContentMap(contentAdd);
2584 Futures.addCallback(
2585 outgoingContentMapFuture,
2586 new FutureCallback<>() {
2587 @Override
2588 public void onSuccess(final RtpContentMap outgoingContentMap) {
2589 sendContentAdd(outgoingContentMap);
2590 webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
2591 }
2592
2593 @Override
2594 public void onFailure(@NonNull Throwable throwable) {
2595 failureToPerformAction(JinglePacket.Action.CONTENT_ADD, throwable);
2596 }
2597 },
2598 MoreExecutors.directExecutor());
2599 }
2600
2601 private void sendContentAdd(final RtpContentMap contentAdd) {
2602
2603 final JinglePacket jinglePacket =
2604 contentAdd.toJinglePacket(JinglePacket.Action.CONTENT_ADD, id.sessionId);
2605 jinglePacket.setTo(id.with);
2606 xmppConnectionService.sendIqPacket(
2607 id.account,
2608 jinglePacket,
2609 (connection, response) -> {
2610 if (response.getType() == IqPacket.TYPE.RESULT) {
2611 Log.d(
2612 Config.LOGTAG,
2613 id.getAccount().getJid().asBareJid()
2614 + ": received ACK to our content-add");
2615 return;
2616 }
2617 if (response.getType() == IqPacket.TYPE.ERROR) {
2618 if (isTieBreak(response)) {
2619 this.outgoingContentAdd = null;
2620 Log.d(Config.LOGTAG, "received tie-break as result of our content-add");
2621 return;
2622 }
2623 handleIqErrorResponse(response);
2624 }
2625 if (response.getType() == IqPacket.TYPE.TIMEOUT) {
2626 handleIqTimeoutResponse(response);
2627 }
2628 });
2629 }
2630
2631 private void setLocalContentMap(final RtpContentMap rtpContentMap) {
2632 if (isInitiator()) {
2633 this.initiatorRtpContentMap = rtpContentMap;
2634 } else {
2635 this.responderRtpContentMap = rtpContentMap;
2636 }
2637 }
2638
2639 private void setRemoteContentMap(final RtpContentMap rtpContentMap) {
2640 if (isInitiator()) {
2641 this.responderRtpContentMap = rtpContentMap;
2642 } else {
2643 this.initiatorRtpContentMap = rtpContentMap;
2644 }
2645 }
2646
2647 // this method is to be used for content map modifications that modify media
2648 private void modifyLocalContentMap(final RtpContentMap rtpContentMap) {
2649 final RtpContentMap activeContents = rtpContentMap.activeContents();
2650 setLocalContentMap(activeContents);
2651 this.callIntegration.setAudioDeviceWhenAvailable(
2652 CallIntegration.initialAudioDevice(activeContents.getMedia()));
2653 updateEndUserState();
2654 }
2655
2656 private SessionDescription setLocalSessionDescription()
2657 throws ExecutionException, InterruptedException {
2658 final org.webrtc.SessionDescription sessionDescription =
2659 this.webRTCWrapper.setLocalDescription(false).get();
2660 return SessionDescription.parse(sessionDescription.description);
2661 }
2662
2663 private void closeWebRTCSessionAfterFailedConnection() {
2664 this.webRTCWrapper.close();
2665 synchronized (this) {
2666 if (isTerminated()) {
2667 Log.d(
2668 Config.LOGTAG,
2669 id.account.getJid().asBareJid()
2670 + ": no need to send session-terminate after failed connection. Other party already did");
2671 return;
2672 }
2673 sendSessionTerminate(Reason.CONNECTIVITY_ERROR);
2674 }
2675 }
2676
2677 public boolean zeroDuration() {
2678 return this.sessionDuration.elapsed(TimeUnit.NANOSECONDS) <= 0;
2679 }
2680
2681 public long getCallDuration() {
2682 return this.sessionDuration.elapsed(TimeUnit.MILLISECONDS);
2683 }
2684
2685 @Override
2686 public CallIntegration getCallIntegration() {
2687 return this.callIntegration;
2688 }
2689
2690 public boolean isMicrophoneEnabled() {
2691 return webRTCWrapper.isMicrophoneEnabled();
2692 }
2693
2694 public boolean setMicrophoneEnabled(final boolean enabled) {
2695 return webRTCWrapper.setMicrophoneEnabledOrThrow(enabled);
2696 }
2697
2698 public boolean isVideoEnabled() {
2699 return webRTCWrapper.isVideoEnabled();
2700 }
2701
2702 public void setVideoEnabled(final boolean enabled) {
2703 webRTCWrapper.setVideoEnabled(enabled);
2704 }
2705
2706 public boolean isCameraSwitchable() {
2707 return webRTCWrapper.isCameraSwitchable();
2708 }
2709
2710 public boolean isFrontCamera() {
2711 return webRTCWrapper.isFrontCamera();
2712 }
2713
2714 public ListenableFuture<Boolean> switchCamera() {
2715 return webRTCWrapper.switchCamera();
2716 }
2717
2718 @Override
2719 public synchronized void onCallIntegrationShowIncomingCallUi() {
2720 if (isTerminated()) {
2721 // there might be race conditions with the call integration service invoking this
2722 // callback when the rtp session has already ended.
2723 Log.w(
2724 Config.LOGTAG,
2725 "CallIntegration requested incoming call UI but session was already terminated");
2726 return;
2727 }
2728 // TODO apparently this can be called too early as well?
2729 xmppConnectionService.getNotificationService().startRinging(id, getMedia());
2730 }
2731
2732 @Override
2733 public void onCallIntegrationDisconnect() {
2734 Log.d(Config.LOGTAG, "a phone call has just been started. killing jingle rtp connections");
2735 if (Arrays.asList(State.PROPOSED, State.SESSION_INITIALIZED).contains(this.state)) {
2736 rejectCall();
2737 } else {
2738 endCall();
2739 }
2740 }
2741
2742 @Override
2743 public void onCallIntegrationReject() {
2744 Log.d(Config.LOGTAG, "rejecting call from system notification / call integration");
2745 try {
2746 rejectCall();
2747 } catch (final IllegalStateException e) {
2748 Log.w(Config.LOGTAG, "race condition on rejecting call from notification", e);
2749 }
2750 }
2751
2752 @Override
2753 public void onCallIntegrationAnswer() {
2754 // we need to start the UI to a) show it and b) be able to ask for permissions
2755 final Intent intent = new Intent(xmppConnectionService, RtpSessionActivity.class);
2756 intent.setAction(RtpSessionActivity.ACTION_ACCEPT_CALL);
2757 intent.putExtra(RtpSessionActivity.EXTRA_ACCOUNT, id.account.getJid().toEscapedString());
2758 intent.putExtra(RtpSessionActivity.EXTRA_WITH, id.with.toEscapedString());
2759 intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
2760 intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK);
2761 intent.putExtra(RtpSessionActivity.EXTRA_SESSION_ID, id.sessionId);
2762 Log.d(Config.LOGTAG, "start activity to accept call from call integration");
2763 xmppConnectionService.startActivity(intent);
2764 }
2765
2766 @Override
2767 public void onCallIntegrationSilence() {
2768 xmppConnectionService.getNotificationService().stopSoundAndVibration();
2769 }
2770
2771 @Override
2772 public void onCallIntegrationMicrophoneEnabled(final boolean enabled) {
2773 this.webRTCWrapper.setMicrophoneEnabled(enabled);
2774 }
2775
2776 @Override
2777 public void onAudioDeviceChanged(
2778 final CallIntegration.AudioDevice selectedAudioDevice,
2779 final Set<CallIntegration.AudioDevice> availableAudioDevices) {
2780 Log.d(
2781 Config.LOGTAG,
2782 "onAudioDeviceChanged(" + selectedAudioDevice + "," + availableAudioDevices + ")");
2783 xmppConnectionService.notifyJingleRtpConnectionUpdate(
2784 selectedAudioDevice, availableAudioDevices);
2785 }
2786
2787 private void updateEndUserState() {
2788 final RtpEndUserState endUserState = getEndUserState();
2789 this.updateCallIntegrationState();
2790 xmppConnectionService.notifyJingleRtpConnectionUpdate(
2791 id.account, id.with, id.sessionId, endUserState);
2792 }
2793
2794 private void updateOngoingCallNotification() {
2795 final State state = this.state;
2796 if (STATES_SHOWING_ONGOING_CALL.contains(state)) {
2797 if (Arrays.asList(State.PROPOSED, State.SESSION_INITIALIZED).contains(state)
2798 && isResponder()) {
2799 Log.d(Config.LOGTAG, "do not set ongoing call during incoming call notification");
2800 xmppConnectionService.removeOngoingCall();
2801 return;
2802 }
2803 final boolean reconnecting;
2804 if (state == State.SESSION_ACCEPTED) {
2805 reconnecting =
2806 getPeerConnectionStateAsEndUserState() == RtpEndUserState.RECONNECTING;
2807 } else {
2808 reconnecting = false;
2809 }
2810 xmppConnectionService.setOngoingCall(id, getMedia(), reconnecting);
2811 } else {
2812 xmppConnectionService.removeOngoingCall();
2813 }
2814 }
2815
2816 private void discoverIceServers(final OnIceServersDiscovered onIceServersDiscovered) {
2817 if (id.account.getXmppConnection().getFeatures().externalServiceDiscovery()) {
2818 final IqPacket request = new IqPacket(IqPacket.TYPE.GET);
2819 request.setTo(id.account.getDomain());
2820 request.addChild("services", Namespace.EXTERNAL_SERVICE_DISCOVERY);
2821 xmppConnectionService.sendIqPacket(
2822 id.account,
2823 request,
2824 (account, response) -> {
2825 final var iceServers = IceServers.parse(response);
2826 if (iceServers.isEmpty()) {
2827 Log.w(
2828 Config.LOGTAG,
2829 id.account.getJid().asBareJid()
2830 + ": no ICE server found "
2831 + response);
2832 }
2833 onIceServersDiscovered.onIceServersDiscovered(iceServers);
2834 });
2835 } else {
2836 Log.w(
2837 Config.LOGTAG,
2838 id.account.getJid().asBareJid() + ": has no external service discovery");
2839 onIceServersDiscovered.onIceServersDiscovered(Collections.emptyList());
2840 }
2841 }
2842
2843 @Override
2844 protected void terminateTransport() {
2845 this.webRTCWrapper.close();
2846 }
2847
2848 @Override
2849 protected void finish() {
2850 if (isTerminated()) {
2851 this.cancelRingingTimeout();
2852 this.callIntegration.verifyDisconnected();
2853 this.webRTCWrapper.verifyClosed();
2854 this.jingleConnectionManager.setTerminalSessionState(id, getEndUserState(), getMedia());
2855 super.finish();
2856 } else {
2857 throw new IllegalStateException(
2858 String.format("Unable to call finish from %s", this.state));
2859 }
2860 }
2861
2862 private void writeLogMessage(final State state) {
2863 final long duration = getCallDuration();
2864 if (state == State.TERMINATED_SUCCESS
2865 || (state == State.TERMINATED_CONNECTIVITY_ERROR && duration > 0)) {
2866 writeLogMessageSuccess(duration);
2867 } else {
2868 writeLogMessageMissed();
2869 }
2870 }
2871
2872 private void writeLogMessageSuccess(final long duration) {
2873 this.message.setBody(new RtpSessionStatus(true, duration).toString());
2874 this.writeMessage();
2875 }
2876
2877 private void writeLogMessageMissed() {
2878 this.message.setBody(new RtpSessionStatus(false, 0).toString());
2879 this.writeMessage();
2880 }
2881
2882 private void writeMessage() {
2883 final Conversational conversational = message.getConversation();
2884 if (conversational instanceof Conversation) {
2885 ((Conversation) conversational).add(this.message);
2886 xmppConnectionService.createMessageAsync(message);
2887 xmppConnectionService.updateConversationUi();
2888 } else {
2889 throw new IllegalStateException("Somehow the conversation in a message was a stub");
2890 }
2891 }
2892
2893 public Optional<VideoTrack> getLocalVideoTrack() {
2894 return webRTCWrapper.getLocalVideoTrack();
2895 }
2896
2897 public Optional<VideoTrack> getRemoteVideoTrack() {
2898 return webRTCWrapper.getRemoteVideoTrack();
2899 }
2900
2901 public EglBase.Context getEglBaseContext() {
2902 return webRTCWrapper.getEglBaseContext();
2903 }
2904
2905 void setProposedMedia(final Set<Media> media) {
2906 this.proposedMedia = media;
2907 this.callIntegration.setVideoState(
2908 Media.audioOnly(media)
2909 ? VideoProfile.STATE_AUDIO_ONLY
2910 : VideoProfile.STATE_BIDIRECTIONAL);
2911 this.callIntegration.setInitialAudioDevice(CallIntegration.initialAudioDevice(media));
2912 }
2913
2914 public void fireStateUpdate() {
2915 final RtpEndUserState endUserState = getEndUserState();
2916 xmppConnectionService.notifyJingleRtpConnectionUpdate(
2917 id.account, id.with, id.sessionId, endUserState);
2918 }
2919
2920 public boolean isSwitchToVideoAvailable() {
2921 final boolean prerequisite =
2922 Media.audioOnly(getMedia())
2923 && Arrays.asList(RtpEndUserState.CONNECTED, RtpEndUserState.RECONNECTING)
2924 .contains(getEndUserState());
2925 return prerequisite && remoteHasVideoFeature();
2926 }
2927
2928 private boolean remoteHasVideoFeature() {
2929 return remoteHasFeature(Namespace.JINGLE_FEATURE_VIDEO);
2930 }
2931
2932 private boolean remoteHasSdpOfferAnswer() {
2933 return remoteHasFeature(Namespace.SDP_OFFER_ANSWER);
2934 }
2935
2936 @Override
2937 public Account getAccount() {
2938 return id.account;
2939 }
2940
2941 @Override
2942 public Jid getWith() {
2943 return id.with;
2944 }
2945
2946 @Override
2947 public String getSessionId() {
2948 return id.sessionId;
2949 }
2950
2951 private interface OnIceServersDiscovered {
2952 void onIceServersDiscovered(List<PeerConnection.IceServer> iceServers);
2953 }
2954}