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