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