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