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