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