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