1package eu.siacs.conversations.xmpp.jingle;
2
3import android.os.SystemClock;
4import android.util.Log;
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.Strings;
12import com.google.common.base.Throwables;
13import com.google.common.collect.Collections2;
14import com.google.common.collect.ImmutableList;
15import com.google.common.collect.ImmutableMap;
16import com.google.common.collect.Sets;
17import com.google.common.primitives.Ints;
18import com.google.common.util.concurrent.FutureCallback;
19import com.google.common.util.concurrent.Futures;
20import com.google.common.util.concurrent.ListenableFuture;
21import com.google.common.util.concurrent.MoreExecutors;
22
23import org.webrtc.EglBase;
24import org.webrtc.IceCandidate;
25import org.webrtc.PeerConnection;
26import org.webrtc.VideoTrack;
27
28import java.util.ArrayDeque;
29import java.util.Arrays;
30import java.util.Collection;
31import java.util.Collections;
32import java.util.List;
33import java.util.Map;
34import java.util.Set;
35import java.util.concurrent.ScheduledFuture;
36import java.util.concurrent.TimeUnit;
37
38import eu.siacs.conversations.Config;
39import eu.siacs.conversations.crypto.axolotl.AxolotlService;
40import eu.siacs.conversations.crypto.axolotl.CryptoFailedException;
41import eu.siacs.conversations.crypto.axolotl.FingerprintStatus;
42import eu.siacs.conversations.entities.Account;
43import eu.siacs.conversations.entities.Conversation;
44import eu.siacs.conversations.entities.Conversational;
45import eu.siacs.conversations.entities.Message;
46import eu.siacs.conversations.entities.RtpSessionStatus;
47import eu.siacs.conversations.services.AppRTCAudioManager;
48import eu.siacs.conversations.utils.IP;
49import eu.siacs.conversations.xml.Element;
50import eu.siacs.conversations.xml.Namespace;
51import eu.siacs.conversations.xmpp.Jid;
52import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
53import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
54import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
55import eu.siacs.conversations.xmpp.jingle.stanzas.Proceed;
56import eu.siacs.conversations.xmpp.jingle.stanzas.Propose;
57import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
58import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
59import eu.siacs.conversations.xmpp.stanzas.IqPacket;
60import eu.siacs.conversations.xmpp.stanzas.MessagePacket;
61
62public class JingleRtpConnection extends AbstractJingleConnection implements WebRTCWrapper.EventCallback {
63
64 public static final List<State> STATES_SHOWING_ONGOING_CALL = Arrays.asList(
65 State.PROCEED,
66 State.SESSION_INITIALIZED_PRE_APPROVED,
67 State.SESSION_ACCEPTED
68 );
69 private static final long BUSY_TIME_OUT = 30;
70 private static final List<State> TERMINATED = Arrays.asList(
71 State.ACCEPTED,
72 State.REJECTED,
73 State.REJECTED_RACED,
74 State.RETRACTED,
75 State.RETRACTED_RACED,
76 State.TERMINATED_SUCCESS,
77 State.TERMINATED_DECLINED_OR_BUSY,
78 State.TERMINATED_CONNECTIVITY_ERROR,
79 State.TERMINATED_CANCEL_OR_TIMEOUT,
80 State.TERMINATED_APPLICATION_FAILURE,
81 State.TERMINATED_SECURITY_ERROR
82 );
83
84 private static final Map<State, Collection<State>> VALID_TRANSITIONS;
85
86 static {
87 final ImmutableMap.Builder<State, Collection<State>> transitionBuilder = new ImmutableMap.Builder<>();
88 transitionBuilder.put(State.NULL, ImmutableList.of(
89 State.PROPOSED,
90 State.SESSION_INITIALIZED,
91 State.TERMINATED_APPLICATION_FAILURE,
92 State.TERMINATED_SECURITY_ERROR
93 ));
94 transitionBuilder.put(State.PROPOSED, ImmutableList.of(
95 State.ACCEPTED,
96 State.PROCEED,
97 State.REJECTED,
98 State.RETRACTED,
99 State.TERMINATED_APPLICATION_FAILURE,
100 State.TERMINATED_SECURITY_ERROR,
101 State.TERMINATED_CONNECTIVITY_ERROR //only used when the xmpp connection rebinds
102 ));
103 transitionBuilder.put(State.PROCEED, ImmutableList.of(
104 State.REJECTED_RACED,
105 State.RETRACTED_RACED,
106 State.SESSION_INITIALIZED_PRE_APPROVED,
107 State.TERMINATED_SUCCESS,
108 State.TERMINATED_APPLICATION_FAILURE,
109 State.TERMINATED_SECURITY_ERROR,
110 State.TERMINATED_CONNECTIVITY_ERROR //at this state used for error bounces of the proceed message
111 ));
112 transitionBuilder.put(State.SESSION_INITIALIZED, ImmutableList.of(
113 State.SESSION_ACCEPTED,
114 State.TERMINATED_SUCCESS,
115 State.TERMINATED_DECLINED_OR_BUSY,
116 State.TERMINATED_CONNECTIVITY_ERROR, //at this state used for IQ errors and IQ timeouts
117 State.TERMINATED_CANCEL_OR_TIMEOUT,
118 State.TERMINATED_APPLICATION_FAILURE,
119 State.TERMINATED_SECURITY_ERROR
120 ));
121 transitionBuilder.put(State.SESSION_INITIALIZED_PRE_APPROVED, ImmutableList.of(
122 State.SESSION_ACCEPTED,
123 State.TERMINATED_SUCCESS,
124 State.TERMINATED_DECLINED_OR_BUSY,
125 State.TERMINATED_CONNECTIVITY_ERROR, //at this state used for IQ errors and IQ timeouts
126 State.TERMINATED_CANCEL_OR_TIMEOUT,
127 State.TERMINATED_APPLICATION_FAILURE,
128 State.TERMINATED_SECURITY_ERROR
129 ));
130 transitionBuilder.put(State.SESSION_ACCEPTED, ImmutableList.of(
131 State.TERMINATED_SUCCESS,
132 State.TERMINATED_DECLINED_OR_BUSY,
133 State.TERMINATED_CONNECTIVITY_ERROR,
134 State.TERMINATED_CANCEL_OR_TIMEOUT,
135 State.TERMINATED_APPLICATION_FAILURE,
136 State.TERMINATED_SECURITY_ERROR
137 ));
138 VALID_TRANSITIONS = transitionBuilder.build();
139 }
140
141 private final WebRTCWrapper webRTCWrapper = new WebRTCWrapper(this);
142 private final ArrayDeque<Set<Map.Entry<String, RtpContentMap.DescriptionTransport>>> pendingIceCandidates = new ArrayDeque<>();
143 private final OmemoVerification omemoVerification = new OmemoVerification();
144 private final Message message;
145 private State state = State.NULL;
146 private StateTransitionException stateTransitionException;
147 private Set<Media> proposedMedia;
148 private RtpContentMap initiatorRtpContentMap;
149 private RtpContentMap responderRtpContentMap;
150 private long rtpConnectionStarted = 0; //time of 'connected'
151 private long rtpConnectionEnded = 0;
152 private ScheduledFuture<?> ringingTimeoutFuture;
153
154 JingleRtpConnection(JingleConnectionManager jingleConnectionManager, Id id, Jid initiator) {
155 super(jingleConnectionManager, id, initiator);
156 final Conversation conversation = jingleConnectionManager.getXmppConnectionService().findOrCreateConversation(
157 id.account,
158 id.with.asBareJid(),
159 false,
160 false
161 );
162 this.message = new Message(
163 conversation,
164 isInitiator() ? Message.STATUS_SEND : Message.STATUS_RECEIVED,
165 Message.TYPE_RTP_SESSION,
166 id.sessionId
167 );
168 }
169
170 private static State reasonToState(Reason reason) {
171 switch (reason) {
172 case SUCCESS:
173 return State.TERMINATED_SUCCESS;
174 case DECLINE:
175 case BUSY:
176 return State.TERMINATED_DECLINED_OR_BUSY;
177 case CANCEL:
178 case TIMEOUT:
179 return State.TERMINATED_CANCEL_OR_TIMEOUT;
180 case SECURITY_ERROR:
181 return State.TERMINATED_SECURITY_ERROR;
182 case FAILED_APPLICATION:
183 case UNSUPPORTED_TRANSPORTS:
184 case UNSUPPORTED_APPLICATIONS:
185 return State.TERMINATED_APPLICATION_FAILURE;
186 default:
187 return State.TERMINATED_CONNECTIVITY_ERROR;
188 }
189 }
190
191 @Override
192 synchronized void deliverPacket(final JinglePacket jinglePacket) {
193 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": packet delivered to JingleRtpConnection");
194 switch (jinglePacket.getAction()) {
195 case SESSION_INITIATE:
196 receiveSessionInitiate(jinglePacket);
197 break;
198 case TRANSPORT_INFO:
199 receiveTransportInfo(jinglePacket);
200 break;
201 case SESSION_ACCEPT:
202 receiveSessionAccept(jinglePacket);
203 break;
204 case SESSION_TERMINATE:
205 receiveSessionTerminate(jinglePacket);
206 break;
207 default:
208 respondOk(jinglePacket);
209 Log.d(Config.LOGTAG, String.format("%s: received unhandled jingle action %s", id.account.getJid().asBareJid(), jinglePacket.getAction()));
210 break;
211 }
212 }
213
214 @Override
215 synchronized void notifyRebound() {
216 if (isTerminated()) {
217 return;
218 }
219 webRTCWrapper.close();
220 if (!isInitiator() && isInState(State.PROPOSED, State.SESSION_INITIALIZED)) {
221 xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
222 }
223 if (isInState(State.SESSION_INITIALIZED, State.SESSION_INITIALIZED_PRE_APPROVED, State.SESSION_ACCEPTED)) {
224 //we might have already changed resources (full jid) at this point; so this might not even reach the other party
225 sendSessionTerminate(Reason.CONNECTIVITY_ERROR);
226 } else {
227 transitionOrThrow(State.TERMINATED_CONNECTIVITY_ERROR);
228 finish();
229 }
230 }
231
232 private void receiveSessionTerminate(final JinglePacket jinglePacket) {
233 respondOk(jinglePacket);
234 final JinglePacket.ReasonWrapper wrapper = jinglePacket.getReason();
235 final State previous = this.state;
236 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received session terminate reason=" + wrapper.reason + "(" + Strings.nullToEmpty(wrapper.text) + ") while in state " + previous);
237 if (TERMINATED.contains(previous)) {
238 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": ignoring session terminate because already in " + previous);
239 return;
240 }
241 webRTCWrapper.close();
242 final State target = reasonToState(wrapper.reason);
243 transitionOrThrow(target);
244 writeLogMessage(target);
245 if (previous == State.PROPOSED || previous == State.SESSION_INITIALIZED) {
246 xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
247 }
248 finish();
249 }
250
251 private void receiveTransportInfo(final JinglePacket jinglePacket) {
252 //Due to the asynchronicity of processing session-init we might move from NULL|PROCEED to INITIALIZED only after transport-info has been received
253 if (isInState(State.NULL, State.PROCEED, State.SESSION_INITIALIZED, State.SESSION_INITIALIZED_PRE_APPROVED, State.SESSION_ACCEPTED)) {
254 respondOk(jinglePacket);
255 final RtpContentMap contentMap;
256 try {
257 contentMap = RtpContentMap.of(jinglePacket);
258 } catch (IllegalArgumentException | NullPointerException e) {
259 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": improperly formatted contents; ignoring", e);
260 return;
261 }
262 final Set<Map.Entry<String, RtpContentMap.DescriptionTransport>> candidates = contentMap.contents.entrySet();
263 if (this.state == State.SESSION_ACCEPTED) {
264 try {
265 processCandidates(candidates);
266 } catch (final WebRTCWrapper.PeerConnectionNotInitialized e) {
267 Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": PeerConnection was not initialized when processing transport info. this usually indicates a race condition that can be ignored");
268 }
269 } else {
270 pendingIceCandidates.push(candidates);
271 }
272 } else {
273 if (isTerminated()) {
274 respondOk(jinglePacket);
275 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": ignoring out-of-order transport info; we where already terminated");
276 } else {
277 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received transport info while in state=" + this.state);
278 terminateWithOutOfOrder(jinglePacket);
279 }
280 }
281 }
282
283 private void processCandidates(final Set<Map.Entry<String, RtpContentMap.DescriptionTransport>> contents) {
284 final RtpContentMap rtpContentMap = isInitiator() ? this.responderRtpContentMap : this.initiatorRtpContentMap;
285 final Group originalGroup = rtpContentMap.group;
286 final List<String> identificationTags = originalGroup == null ? rtpContentMap.getNames() : originalGroup.getIdentificationTags();
287 if (identificationTags.size() == 0) {
288 Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": no identification tags found in initial offer. we won't be able to calculate mLineIndices");
289 }
290 processCandidates(identificationTags, contents);
291 }
292
293 private void processCandidates(final List<String> indices, final Set<Map.Entry<String, RtpContentMap.DescriptionTransport>> contents) {
294 for (final Map.Entry<String, RtpContentMap.DescriptionTransport> content : contents) {
295 final String ufrag = content.getValue().transport.getAttribute("ufrag");
296 for (final IceUdpTransportInfo.Candidate candidate : content.getValue().transport.getCandidates()) {
297 final String sdp;
298 try {
299 sdp = candidate.toSdpAttribute(ufrag);
300 } catch (IllegalArgumentException e) {
301 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": ignoring invalid ICE candidate " + e.getMessage());
302 continue;
303 }
304 final String sdpMid = content.getKey();
305 final int mLineIndex = indices.indexOf(sdpMid);
306 if (mLineIndex < 0) {
307 Log.w(Config.LOGTAG, "mLineIndex not found for " + sdpMid + ". available indices " + indices);
308 }
309 final IceCandidate iceCandidate = new IceCandidate(sdpMid, mLineIndex, sdp);
310 Log.d(Config.LOGTAG, "received candidate: " + iceCandidate);
311 this.webRTCWrapper.addIceCandidate(iceCandidate);
312 }
313 }
314 }
315
316 private ListenableFuture<RtpContentMap> receiveRtpContentMap(final JinglePacket jinglePacket, final boolean expectVerification) {
317 final RtpContentMap receivedContentMap;
318 try {
319 receivedContentMap = RtpContentMap.of(jinglePacket);
320 } catch (final Exception e) {
321 return Futures.immediateFailedFuture(e);
322 }
323 if (receivedContentMap instanceof OmemoVerifiedRtpContentMap) {
324 final ListenableFuture<AxolotlService.OmemoVerifiedPayload<RtpContentMap>> future = id.account.getAxolotlService().decrypt((OmemoVerifiedRtpContentMap) receivedContentMap, id.with);
325 return Futures.transform(future, omemoVerifiedPayload -> {
326 //TODO test if an exception here triggers a correct abort
327 omemoVerification.setOrEnsureEqual(omemoVerifiedPayload);
328 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received verifiable DTLS fingerprint via " + omemoVerification);
329 return omemoVerifiedPayload.getPayload();
330 }, MoreExecutors.directExecutor());
331 } else if (Config.REQUIRE_RTP_VERIFICATION || expectVerification) {
332 return Futures.immediateFailedFuture(
333 new SecurityException("DTLS fingerprint was unexpectedly not verifiable")
334 );
335 } else {
336 return Futures.immediateFuture(receivedContentMap);
337 }
338 }
339
340 private void receiveSessionInitiate(final JinglePacket jinglePacket) {
341 if (isInitiator()) {
342 Log.d(Config.LOGTAG, String.format("%s: received session-initiate even though we were initiating", id.account.getJid().asBareJid()));
343 if (isTerminated()) {
344 Log.d(Config.LOGTAG, String.format(
345 "%s: got a reason to terminate with out-of-order. but already in state %s",
346 id.account.getJid().asBareJid(),
347 getState()
348 ));
349 respondWithOutOfOrder(jinglePacket);
350 } else {
351 terminateWithOutOfOrder(jinglePacket);
352 }
353 return;
354 }
355 final ListenableFuture<RtpContentMap> future = receiveRtpContentMap(jinglePacket, false);
356 Futures.addCallback(future, new FutureCallback<RtpContentMap>() {
357 @Override
358 public void onSuccess(@Nullable RtpContentMap rtpContentMap) {
359 receiveSessionInitiate(jinglePacket, rtpContentMap);
360 }
361
362 @Override
363 public void onFailure(@NonNull final Throwable throwable) {
364 respondOk(jinglePacket);
365 sendSessionTerminate(Reason.ofThrowable(throwable), throwable.getMessage());
366 }
367 }, MoreExecutors.directExecutor());
368 }
369
370 private void receiveSessionInitiate(final JinglePacket jinglePacket, final RtpContentMap contentMap) {
371 try {
372 contentMap.requireContentDescriptions();
373 contentMap.requireDTLSFingerprint();
374 } catch (final RuntimeException e) {
375 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": improperly formatted contents", Throwables.getRootCause(e));
376 respondOk(jinglePacket);
377 sendSessionTerminate(Reason.of(e), e.getMessage());
378 return;
379 }
380 Log.d(Config.LOGTAG, "processing session-init with " + contentMap.contents.size() + " contents");
381 final State target;
382 if (this.state == State.PROCEED) {
383 Preconditions.checkState(
384 proposedMedia != null && proposedMedia.size() > 0,
385 "proposed media must be set when processing pre-approved session-initiate"
386 );
387 if (!this.proposedMedia.equals(contentMap.getMedia())) {
388 sendSessionTerminate(Reason.SECURITY_ERROR, String.format(
389 "Your session proposal (Jingle Message Initiation) included media %s but your session-initiate was %s",
390 this.proposedMedia,
391 contentMap.getMedia()
392 ));
393 return;
394 }
395 target = State.SESSION_INITIALIZED_PRE_APPROVED;
396 } else {
397 target = State.SESSION_INITIALIZED;
398 }
399 if (transition(target, () -> this.initiatorRtpContentMap = contentMap)) {
400 respondOk(jinglePacket);
401
402 final Set<Map.Entry<String, RtpContentMap.DescriptionTransport>> candidates = contentMap.contents.entrySet();
403 if (candidates.size() > 0) {
404 pendingIceCandidates.push(candidates);
405 }
406 if (target == State.SESSION_INITIALIZED_PRE_APPROVED) {
407 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": automatically accepting session-initiate");
408 sendSessionAccept();
409 } else {
410 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received not pre-approved session-initiate. start ringing");
411 startRinging();
412 }
413 } else {
414 Log.d(Config.LOGTAG, String.format("%s: received session-initiate while in state %s", id.account.getJid().asBareJid(), state));
415 terminateWithOutOfOrder(jinglePacket);
416 }
417 }
418
419 private void receiveSessionAccept(final JinglePacket jinglePacket) {
420 if (!isInitiator()) {
421 Log.d(Config.LOGTAG, String.format("%s: received session-accept even though we were responding", id.account.getJid().asBareJid()));
422 terminateWithOutOfOrder(jinglePacket);
423 return;
424 }
425 final ListenableFuture<RtpContentMap> future = receiveRtpContentMap(jinglePacket, this.omemoVerification.hasFingerprint());
426 Futures.addCallback(future, new FutureCallback<RtpContentMap>() {
427 @Override
428 public void onSuccess(@Nullable RtpContentMap rtpContentMap) {
429 receiveSessionAccept(jinglePacket, rtpContentMap);
430 }
431
432 @Override
433 public void onFailure(@NonNull final Throwable throwable) {
434 respondOk(jinglePacket);
435 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": improperly formatted contents in session-accept", throwable);
436 webRTCWrapper.close();
437 sendSessionTerminate(Reason.ofThrowable(throwable), throwable.getMessage());
438 }
439 }, MoreExecutors.directExecutor());
440 }
441
442 private void receiveSessionAccept(final JinglePacket jinglePacket, final RtpContentMap contentMap) {
443 try {
444 contentMap.requireContentDescriptions();
445 contentMap.requireDTLSFingerprint();
446 } catch (final RuntimeException e) {
447 respondOk(jinglePacket);
448 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": improperly formatted contents in session-accept", e);
449 webRTCWrapper.close();
450 sendSessionTerminate(Reason.of(e), e.getMessage());
451 return;
452 }
453 final Set<Media> initiatorMedia = this.initiatorRtpContentMap.getMedia();
454 if (!initiatorMedia.equals(contentMap.getMedia())) {
455 sendSessionTerminate(Reason.SECURITY_ERROR, String.format(
456 "Your session-included included media %s but our session-initiate was %s",
457 this.proposedMedia,
458 contentMap.getMedia()
459 ));
460 return;
461 }
462 Log.d(Config.LOGTAG, "processing session-accept with " + contentMap.contents.size() + " contents");
463 if (transition(State.SESSION_ACCEPTED)) {
464 respondOk(jinglePacket);
465 receiveSessionAccept(contentMap);
466 } else {
467 Log.d(Config.LOGTAG, String.format("%s: received session-accept while in state %s", id.account.getJid().asBareJid(), state));
468 respondOk(jinglePacket);
469 }
470 }
471
472 private void receiveSessionAccept(final RtpContentMap contentMap) {
473 this.responderRtpContentMap = contentMap;
474 final SessionDescription sessionDescription;
475 try {
476 sessionDescription = SessionDescription.of(contentMap);
477 } catch (final IllegalArgumentException | NullPointerException e) {
478 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable convert offer from session-accept to SDP", e);
479 webRTCWrapper.close();
480 sendSessionTerminate(Reason.FAILED_APPLICATION, e.getMessage());
481 return;
482 }
483 final org.webrtc.SessionDescription answer = new org.webrtc.SessionDescription(
484 org.webrtc.SessionDescription.Type.ANSWER,
485 sessionDescription.toString()
486 );
487 try {
488 this.webRTCWrapper.setRemoteDescription(answer).get();
489 } catch (final Exception e) {
490 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to set remote description after receiving session-accept", Throwables.getRootCause(e));
491 webRTCWrapper.close();
492 sendSessionTerminate(Reason.FAILED_APPLICATION);
493 return;
494 }
495 final List<String> identificationTags = contentMap.group == null ? contentMap.getNames() : contentMap.group.getIdentificationTags();
496 processCandidates(identificationTags, contentMap.contents.entrySet());
497 }
498
499 private void sendSessionAccept() {
500 final RtpContentMap rtpContentMap = this.initiatorRtpContentMap;
501 if (rtpContentMap == null) {
502 throw new IllegalStateException("initiator RTP Content Map has not been set");
503 }
504 final SessionDescription offer;
505 try {
506 offer = SessionDescription.of(rtpContentMap);
507 } catch (final IllegalArgumentException | NullPointerException e) {
508 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable convert offer from session-initiate to SDP", e);
509 webRTCWrapper.close();
510 sendSessionTerminate(Reason.FAILED_APPLICATION, e.getMessage());
511 return;
512 }
513 sendSessionAccept(rtpContentMap.getMedia(), offer);
514 }
515
516 private void sendSessionAccept(final Set<Media> media, final SessionDescription offer) {
517 discoverIceServers(iceServers -> sendSessionAccept(media, offer, iceServers));
518 }
519
520 private synchronized void sendSessionAccept(final Set<Media> media, final SessionDescription offer, final List<PeerConnection.IceServer> iceServers) {
521 if (isTerminated()) {
522 Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": ICE servers got discovered when session was already terminated. nothing to do.");
523 return;
524 }
525 try {
526 setupWebRTC(media, iceServers);
527 } catch (final WebRTCWrapper.InitializationException e) {
528 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to initialize WebRTC");
529 webRTCWrapper.close();
530 sendSessionTerminate(Reason.FAILED_APPLICATION);
531 return;
532 }
533 final org.webrtc.SessionDescription sdp = new org.webrtc.SessionDescription(
534 org.webrtc.SessionDescription.Type.OFFER,
535 offer.toString()
536 );
537 try {
538 this.webRTCWrapper.setRemoteDescription(sdp).get();
539 addIceCandidatesFromBlackLog();
540 org.webrtc.SessionDescription webRTCSessionDescription = this.webRTCWrapper.createAnswer().get();
541 prepareSessionAccept(webRTCSessionDescription);
542 } catch (final Exception e) {
543 failureToAcceptSession(e);
544 }
545 }
546
547 private void failureToAcceptSession(final Throwable throwable) {
548 if (isTerminated()) {
549 return;
550 }
551 Log.d(Config.LOGTAG, "unable to send session accept", Throwables.getRootCause(throwable));
552 webRTCWrapper.close();
553 sendSessionTerminate(Reason.ofThrowable(throwable));
554 }
555
556 private void addIceCandidatesFromBlackLog() {
557 while (!this.pendingIceCandidates.isEmpty()) {
558 processCandidates(this.pendingIceCandidates.poll());
559 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": added candidates from back log");
560 }
561 }
562
563 private void prepareSessionAccept(final org.webrtc.SessionDescription webRTCSessionDescription) {
564 final SessionDescription sessionDescription = SessionDescription.parse(webRTCSessionDescription.description);
565 final RtpContentMap respondingRtpContentMap = RtpContentMap.of(sessionDescription);
566 this.responderRtpContentMap = respondingRtpContentMap;
567 final ListenableFuture<RtpContentMap> outgoingContentMapFuture = prepareOutgoingContentMap(respondingRtpContentMap);
568 Futures.addCallback(outgoingContentMapFuture,
569 new FutureCallback<RtpContentMap>() {
570 @Override
571 public void onSuccess(final RtpContentMap outgoingContentMap) {
572 sendSessionAccept(outgoingContentMap, webRTCSessionDescription);
573 }
574
575 @Override
576 public void onFailure(@NonNull Throwable throwable) {
577 failureToAcceptSession(throwable);
578 }
579 },
580 MoreExecutors.directExecutor()
581 );
582 }
583
584 private void sendSessionAccept(final RtpContentMap rtpContentMap, final org.webrtc.SessionDescription webRTCSessionDescription) {
585 if (isTerminated()) {
586 Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": preparing session accept was too slow. already terminated. nothing to do.");
587 return;
588 }
589 transitionOrThrow(State.SESSION_ACCEPTED);
590 final JinglePacket sessionAccept = rtpContentMap.toJinglePacket(JinglePacket.Action.SESSION_ACCEPT, id.sessionId);
591 send(sessionAccept);
592 try {
593 webRTCWrapper.setLocalDescription(webRTCSessionDescription).get();
594 } catch (Exception e) {
595 failureToAcceptSession(e);
596 }
597 }
598
599 private ListenableFuture<RtpContentMap> prepareOutgoingContentMap(final RtpContentMap rtpContentMap) {
600 if (this.omemoVerification.hasDeviceId()) {
601 ListenableFuture<AxolotlService.OmemoVerifiedPayload<OmemoVerifiedRtpContentMap>> verifiedPayloadFuture = id.account.getAxolotlService()
602 .encrypt(rtpContentMap, id.with, omemoVerification.getDeviceId());
603 return Futures.transform(verifiedPayloadFuture, verifiedPayload -> {
604 omemoVerification.setOrEnsureEqual(verifiedPayload);
605 return verifiedPayload.getPayload();
606 }, MoreExecutors.directExecutor());
607 } else {
608 return Futures.immediateFuture(rtpContentMap);
609 }
610 }
611
612 synchronized void deliveryMessage(final Jid from, final Element message, final String serverMessageId, final long timestamp) {
613 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": delivered message to JingleRtpConnection " + message);
614 switch (message.getName()) {
615 case "propose":
616 receivePropose(from, Propose.upgrade(message), serverMessageId, timestamp);
617 break;
618 case "proceed":
619 receiveProceed(from, Proceed.upgrade(message), serverMessageId, timestamp);
620 break;
621 case "retract":
622 receiveRetract(from, serverMessageId, timestamp);
623 break;
624 case "reject":
625 receiveReject(from, serverMessageId, timestamp);
626 break;
627 case "accept":
628 receiveAccept(from, serverMessageId, timestamp);
629 break;
630 default:
631 break;
632 }
633 }
634
635 void deliverFailedProceed() {
636 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": receive message error for proceed message");
637 if (transition(State.TERMINATED_CONNECTIVITY_ERROR)) {
638 webRTCWrapper.close();
639 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": transitioned into connectivity error");
640 this.finish();
641 }
642 }
643
644 private void receiveAccept(final Jid from, final String serverMsgId, final long timestamp) {
645 final boolean originatedFromMyself = from.asBareJid().equals(id.account.getJid().asBareJid());
646 if (originatedFromMyself) {
647 if (transition(State.ACCEPTED)) {
648 if (serverMsgId != null) {
649 this.message.setServerMsgId(serverMsgId);
650 }
651 this.message.setTime(timestamp);
652 this.message.setCarbon(true); //indicate that call was accepted on other device
653 this.writeLogMessageSuccess(0);
654 this.xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
655 this.finish();
656 } else {
657 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to transition to accept because already in state=" + this.state);
658 }
659 } else {
660 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": ignoring 'accept' from " + from);
661 }
662 }
663
664 private void receiveReject(final Jid from, final String serverMsgId, final long timestamp) {
665 final boolean originatedFromMyself = from.asBareJid().equals(id.account.getJid().asBareJid());
666 //reject from another one of my clients
667 if (originatedFromMyself) {
668 receiveRejectFromMyself(serverMsgId, timestamp);
669 } else if (isInitiator()) {
670 if (from.equals(id.with)) {
671 receiveRejectFromResponder();
672 } else {
673 Log.d(Config.LOGTAG, id.account.getJid() + ": ignoring reject from " + from + " for session with " + id.with);
674 }
675 } else {
676 Log.d(Config.LOGTAG, id.account.getJid() + ": ignoring reject from " + from + " for session with " + id.with);
677 }
678 }
679
680 private void receiveRejectFromMyself(String serverMsgId, long timestamp) {
681 if (transition(State.REJECTED)) {
682 this.xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
683 this.finish();
684 if (serverMsgId != null) {
685 this.message.setServerMsgId(serverMsgId);
686 }
687 this.message.setTime(timestamp);
688 this.message.setCarbon(true); //indicate that call was rejected on other device
689 writeLogMessageMissed();
690 } else {
691 Log.d(Config.LOGTAG, "not able to transition into REJECTED because already in " + this.state);
692 }
693 }
694
695 private void receiveRejectFromResponder() {
696 if (isInState(State.PROCEED)) {
697 Log.d(Config.LOGTAG, id.account.getJid() + ": received reject while still in proceed. callee reconsidered");
698 closeTransitionLogFinish(State.REJECTED_RACED);
699 return;
700 }
701 if (isInState(State.SESSION_INITIALIZED_PRE_APPROVED)) {
702 Log.d(Config.LOGTAG, id.account.getJid() + ": received reject while in SESSION_INITIATED_PRE_APPROVED. callee reconsidered before receiving session-init");
703 closeTransitionLogFinish(State.TERMINATED_DECLINED_OR_BUSY);
704 return;
705 }
706 Log.d(Config.LOGTAG, id.account.getJid() + ": ignoring reject from responder because already in state " + this.state);
707 }
708
709 private void receivePropose(final Jid from, final Propose propose, final String serverMsgId, final long timestamp) {
710 final boolean originatedFromMyself = from.asBareJid().equals(id.account.getJid().asBareJid());
711 if (originatedFromMyself) {
712 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": saw proposal from myself. ignoring");
713 } else if (transition(State.PROPOSED, () -> {
714 final Collection<RtpDescription> descriptions = Collections2.transform(
715 Collections2.filter(propose.getDescriptions(), d -> d instanceof RtpDescription),
716 input -> (RtpDescription) input
717 );
718 final Collection<Media> media = Collections2.transform(descriptions, RtpDescription::getMedia);
719 Preconditions.checkState(!media.contains(Media.UNKNOWN), "RTP descriptions contain unknown media");
720 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received session proposal from " + from + " for " + media);
721 this.proposedMedia = Sets.newHashSet(media);
722 })) {
723 if (serverMsgId != null) {
724 this.message.setServerMsgId(serverMsgId);
725 }
726 this.message.setTime(timestamp);
727 startRinging();
728 } else {
729 Log.d(Config.LOGTAG, id.account.getJid() + ": ignoring session proposal because already in " + state);
730 }
731 }
732
733 private void startRinging() {
734 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received call from " + id.with + ". start ringing");
735 ringingTimeoutFuture = jingleConnectionManager.schedule(this::ringingTimeout, BUSY_TIME_OUT, TimeUnit.SECONDS);
736 xmppConnectionService.getNotificationService().startRinging(id, getMedia());
737 }
738
739 private synchronized void ringingTimeout() {
740 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": timeout reached for ringing");
741 switch (this.state) {
742 case PROPOSED:
743 message.markUnread();
744 rejectCallFromProposed();
745 break;
746 case SESSION_INITIALIZED:
747 message.markUnread();
748 rejectCallFromSessionInitiate();
749 break;
750 }
751 }
752
753 private void cancelRingingTimeout() {
754 final ScheduledFuture<?> future = this.ringingTimeoutFuture;
755 if (future != null && !future.isCancelled()) {
756 future.cancel(false);
757 }
758 }
759
760 private void receiveProceed(final Jid from, final Proceed proceed, final String serverMsgId, final long timestamp) {
761 final Set<Media> media = Preconditions.checkNotNull(this.proposedMedia, "Proposed media has to be set before handling proceed");
762 Preconditions.checkState(media.size() > 0, "Proposed media should not be empty");
763 if (from.equals(id.with)) {
764 if (isInitiator()) {
765 if (transition(State.PROCEED)) {
766 if (serverMsgId != null) {
767 this.message.setServerMsgId(serverMsgId);
768 }
769 this.message.setTime(timestamp);
770 final Integer remoteDeviceId = proceed.getDeviceId();
771 if (isOmemoEnabled()) {
772 this.omemoVerification.setDeviceId(remoteDeviceId);
773 } else {
774 if (remoteDeviceId != null) {
775 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": remote party signaled support for OMEMO verification but we have OMEMO disabled");
776 }
777 this.omemoVerification.setDeviceId(null);
778 }
779 this.sendSessionInitiate(media, State.SESSION_INITIALIZED_PRE_APPROVED);
780 } else {
781 Log.d(Config.LOGTAG, String.format("%s: ignoring proceed because already in %s", id.account.getJid().asBareJid(), this.state));
782 }
783 } else {
784 Log.d(Config.LOGTAG, String.format("%s: ignoring proceed because we were not initializing", id.account.getJid().asBareJid()));
785 }
786 } else if (from.asBareJid().equals(id.account.getJid().asBareJid())) {
787 if (transition(State.ACCEPTED)) {
788 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": moved session with " + id.with + " into state accepted after received carbon copied procced");
789 this.xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
790 this.finish();
791 }
792 } else {
793 Log.d(Config.LOGTAG, String.format("%s: ignoring proceed from %s. was expected from %s", id.account.getJid().asBareJid(), from, id.with));
794 }
795 }
796
797 private void receiveRetract(final Jid from, final String serverMsgId, final long timestamp) {
798 if (from.equals(id.with)) {
799 final State target = this.state == State.PROCEED ? State.RETRACTED_RACED : State.RETRACTED;
800 if (transition(target)) {
801 xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
802 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": session with " + id.with + " has been retracted (serverMsgId=" + serverMsgId + ")");
803 if (serverMsgId != null) {
804 this.message.setServerMsgId(serverMsgId);
805 }
806 this.message.setTime(timestamp);
807 if (target == State.RETRACTED) {
808 this.message.markUnread();
809 }
810 writeLogMessageMissed();
811 finish();
812 } else {
813 Log.d(Config.LOGTAG, "ignoring retract because already in " + this.state);
814 }
815 } else {
816 //TODO parse retract from self
817 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received retract from " + from + ". expected retract from" + id.with + ". ignoring");
818 }
819 }
820
821 public void sendSessionInitiate() {
822 sendSessionInitiate(this.proposedMedia, State.SESSION_INITIALIZED);
823 }
824
825 private void sendSessionInitiate(final Set<Media> media, final State targetState) {
826 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": prepare session-initiate");
827 discoverIceServers(iceServers -> sendSessionInitiate(media, targetState, iceServers));
828 }
829
830 private synchronized void sendSessionInitiate(final Set<Media> media, final State targetState, final List<PeerConnection.IceServer> iceServers) {
831 if (isTerminated()) {
832 Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": ICE servers got discovered when session was already terminated. nothing to do.");
833 return;
834 }
835 try {
836 setupWebRTC(media, iceServers);
837 } catch (final WebRTCWrapper.InitializationException e) {
838 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to initialize WebRTC");
839 webRTCWrapper.close();
840 sendRetract(Reason.ofThrowable(e));
841 return;
842 }
843 try {
844 org.webrtc.SessionDescription webRTCSessionDescription = this.webRTCWrapper.createOffer().get();
845 prepareSessionInitiate(webRTCSessionDescription, targetState);
846 } catch (final Exception e) {
847 failureToInitiateSession(e, targetState);
848 }
849 }
850
851 private void failureToInitiateSession(final Throwable throwable, final State targetState) {
852 if (isTerminated()) {
853 return;
854 }
855 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to sendSessionInitiate", Throwables.getRootCause(throwable));
856 webRTCWrapper.close();
857 final Reason reason = Reason.ofThrowable(throwable);
858 if (isInState(targetState)) {
859 sendSessionTerminate(reason);
860 } else {
861 sendRetract(reason);
862 }
863 }
864
865 private void sendRetract(final Reason reason) {
866 //TODO embed reason into retract
867 sendJingleMessage("retract", id.with.asBareJid());
868 transitionOrThrow(reasonToState(reason));
869 this.finish();
870 }
871
872 private void prepareSessionInitiate(final org.webrtc.SessionDescription webRTCSessionDescription, final State targetState) {
873 final SessionDescription sessionDescription = SessionDescription.parse(webRTCSessionDescription.description);
874 final RtpContentMap rtpContentMap = RtpContentMap.of(sessionDescription);
875 this.initiatorRtpContentMap = rtpContentMap;
876 final ListenableFuture<RtpContentMap> outgoingContentMapFuture = encryptSessionInitiate(rtpContentMap);
877 Futures.addCallback(outgoingContentMapFuture, new FutureCallback<RtpContentMap>() {
878 @Override
879 public void onSuccess(final RtpContentMap outgoingContentMap) {
880 sendSessionInitiate(outgoingContentMap, webRTCSessionDescription, targetState);
881 }
882
883 @Override
884 public void onFailure(@NonNull final Throwable throwable) {
885 failureToInitiateSession(throwable, targetState);
886 }
887 }, MoreExecutors.directExecutor());
888 }
889
890 private void sendSessionInitiate(final RtpContentMap rtpContentMap, final org.webrtc.SessionDescription webRTCSessionDescription, final State targetState) {
891 if (isTerminated()) {
892 Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": preparing session was too slow. already terminated. nothing to do.");
893 return;
894 }
895 this.transitionOrThrow(targetState);
896 final JinglePacket sessionInitiate = rtpContentMap.toJinglePacket(JinglePacket.Action.SESSION_INITIATE, id.sessionId);
897 send(sessionInitiate);
898 try {
899 this.webRTCWrapper.setLocalDescription(webRTCSessionDescription).get();
900 } catch (Exception e) {
901 failureToInitiateSession(e, targetState);
902 }
903 }
904
905 private ListenableFuture<RtpContentMap> encryptSessionInitiate(final RtpContentMap rtpContentMap) {
906 if (this.omemoVerification.hasDeviceId()) {
907 final ListenableFuture<AxolotlService.OmemoVerifiedPayload<OmemoVerifiedRtpContentMap>> verifiedPayloadFuture = id.account.getAxolotlService()
908 .encrypt(rtpContentMap, id.with, omemoVerification.getDeviceId());
909 final ListenableFuture<RtpContentMap> future = Futures.transform(verifiedPayloadFuture, verifiedPayload -> {
910 omemoVerification.setSessionFingerprint(verifiedPayload.getFingerprint());
911 return verifiedPayload.getPayload();
912 }, MoreExecutors.directExecutor());
913 if (Config.REQUIRE_RTP_VERIFICATION) {
914 return future;
915 }
916 return Futures.catching(
917 future,
918 CryptoFailedException.class,
919 e -> {
920 Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to use OMEMO DTLS verification on outgoing session initiate. falling back", e);
921 return rtpContentMap;
922 },
923 MoreExecutors.directExecutor()
924 );
925 } else {
926 return Futures.immediateFuture(rtpContentMap);
927 }
928 }
929
930 private void sendSessionTerminate(final Reason reason) {
931 sendSessionTerminate(reason, null);
932 }
933
934 private void sendSessionTerminate(final Reason reason, final String text) {
935 final State previous = this.state;
936 final State target = reasonToState(reason);
937 transitionOrThrow(target);
938 if (previous != State.NULL) {
939 writeLogMessage(target);
940 }
941 final JinglePacket jinglePacket = new JinglePacket(JinglePacket.Action.SESSION_TERMINATE, id.sessionId);
942 jinglePacket.setReason(reason, text);
943 Log.d(Config.LOGTAG, jinglePacket.toString());
944 send(jinglePacket);
945 finish();
946 }
947
948 private void sendTransportInfo(final String contentName, IceUdpTransportInfo.Candidate candidate) {
949 final RtpContentMap transportInfo;
950 try {
951 final RtpContentMap rtpContentMap = isInitiator() ? this.initiatorRtpContentMap : this.responderRtpContentMap;
952 transportInfo = rtpContentMap.transportInfo(contentName, candidate);
953 } catch (final Exception e) {
954 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to prepare transport-info from candidate for content=" + contentName);
955 return;
956 }
957 final JinglePacket jinglePacket = transportInfo.toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
958 send(jinglePacket);
959 }
960
961 private void send(final JinglePacket jinglePacket) {
962 jinglePacket.setTo(id.with);
963 xmppConnectionService.sendIqPacket(id.account, jinglePacket, this::handleIqResponse);
964 }
965
966 private synchronized void handleIqResponse(final Account account, final IqPacket response) {
967 if (response.getType() == IqPacket.TYPE.ERROR) {
968 final String errorCondition = response.getErrorCondition();
969 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received IQ-error from " + response.getFrom() + " in RTP session. " + errorCondition);
970 if (isTerminated()) {
971 Log.i(Config.LOGTAG, id.account.getJid().asBareJid() + ": ignoring error because session was already terminated");
972 return;
973 }
974 this.webRTCWrapper.close();
975 final State target;
976 if (Arrays.asList(
977 "service-unavailable",
978 "recipient-unavailable",
979 "remote-server-not-found",
980 "remote-server-timeout"
981 ).contains(errorCondition)) {
982 target = State.TERMINATED_CONNECTIVITY_ERROR;
983 } else {
984 target = State.TERMINATED_APPLICATION_FAILURE;
985 }
986 transitionOrThrow(target);
987 this.finish();
988 } else if (response.getType() == IqPacket.TYPE.TIMEOUT) {
989 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received IQ timeout in RTP session with " + id.with + ". terminating with connectivity error");
990 if (isTerminated()) {
991 Log.i(Config.LOGTAG, id.account.getJid().asBareJid() + ": ignoring error because session was already terminated");
992 return;
993 }
994 this.webRTCWrapper.close();
995 transitionOrThrow(State.TERMINATED_CONNECTIVITY_ERROR);
996 this.finish();
997 }
998 }
999
1000 private void terminateWithOutOfOrder(final JinglePacket jinglePacket) {
1001 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": terminating session with out-of-order");
1002 this.webRTCWrapper.close();
1003 transitionOrThrow(State.TERMINATED_APPLICATION_FAILURE);
1004 respondWithOutOfOrder(jinglePacket);
1005 this.finish();
1006 }
1007
1008 private void respondWithOutOfOrder(final JinglePacket jinglePacket) {
1009 jingleConnectionManager.respondWithJingleError(id.account, jinglePacket, "out-of-order", "unexpected-request", "wait");
1010 }
1011
1012 private void respondOk(final JinglePacket jinglePacket) {
1013 xmppConnectionService.sendIqPacket(id.account, jinglePacket.generateResponse(IqPacket.TYPE.RESULT), null);
1014 }
1015
1016 public void throwStateTransitionException() {
1017 final StateTransitionException exception = this.stateTransitionException;
1018 if (exception != null) {
1019 throw new IllegalStateException(String.format("Transition to %s did not call finish", exception.state), exception);
1020 }
1021 }
1022
1023 public RtpEndUserState getEndUserState() {
1024 switch (this.state) {
1025 case NULL:
1026 case PROPOSED:
1027 case SESSION_INITIALIZED:
1028 if (isInitiator()) {
1029 return RtpEndUserState.RINGING;
1030 } else {
1031 return RtpEndUserState.INCOMING_CALL;
1032 }
1033 case PROCEED:
1034 if (isInitiator()) {
1035 return RtpEndUserState.RINGING;
1036 } else {
1037 return RtpEndUserState.ACCEPTING_CALL;
1038 }
1039 case SESSION_INITIALIZED_PRE_APPROVED:
1040 if (isInitiator()) {
1041 return RtpEndUserState.RINGING;
1042 } else {
1043 return RtpEndUserState.CONNECTING;
1044 }
1045 case SESSION_ACCEPTED:
1046 //TODO refactor this out into separate method (that uses switch for better readability)
1047 final PeerConnection.PeerConnectionState state;
1048 try {
1049 state = webRTCWrapper.getState();
1050 } catch (final WebRTCWrapper.PeerConnectionNotInitialized e) {
1051 //We usually close the WebRTCWrapper *before* transitioning so we might still
1052 //be in SESSION_ACCEPTED even though the peerConnection has been torn down
1053 return RtpEndUserState.ENDING_CALL;
1054 }
1055 if (state == PeerConnection.PeerConnectionState.CONNECTED) {
1056 return RtpEndUserState.CONNECTED;
1057 } else if (state == PeerConnection.PeerConnectionState.NEW || state == PeerConnection.PeerConnectionState.CONNECTING) {
1058 return RtpEndUserState.CONNECTING;
1059 } else if (state == PeerConnection.PeerConnectionState.CLOSED) {
1060 return RtpEndUserState.ENDING_CALL;
1061 } else {
1062 return rtpConnectionStarted == 0 ? RtpEndUserState.CONNECTIVITY_ERROR : RtpEndUserState.CONNECTIVITY_LOST_ERROR;
1063 }
1064 case REJECTED:
1065 case REJECTED_RACED:
1066 case TERMINATED_DECLINED_OR_BUSY:
1067 if (isInitiator()) {
1068 return RtpEndUserState.DECLINED_OR_BUSY;
1069 } else {
1070 return RtpEndUserState.ENDED;
1071 }
1072 case TERMINATED_SUCCESS:
1073 case ACCEPTED:
1074 case RETRACTED:
1075 case TERMINATED_CANCEL_OR_TIMEOUT:
1076 return RtpEndUserState.ENDED;
1077 case RETRACTED_RACED:
1078 if (isInitiator()) {
1079 return RtpEndUserState.ENDED;
1080 } else {
1081 return RtpEndUserState.RETRACTED;
1082 }
1083 case TERMINATED_CONNECTIVITY_ERROR:
1084 return rtpConnectionStarted == 0 ? RtpEndUserState.CONNECTIVITY_ERROR : RtpEndUserState.CONNECTIVITY_LOST_ERROR;
1085 case TERMINATED_APPLICATION_FAILURE:
1086 return RtpEndUserState.APPLICATION_ERROR;
1087 case TERMINATED_SECURITY_ERROR:
1088 return RtpEndUserState.SECURITY_ERROR;
1089 }
1090 throw new IllegalStateException(String.format("%s has no equivalent EndUserState", this.state));
1091 }
1092
1093 public Set<Media> getMedia() {
1094 final State current = getState();
1095 if (current == State.NULL) {
1096 if (isInitiator()) {
1097 return Preconditions.checkNotNull(
1098 this.proposedMedia,
1099 "RTP connection has not been initialized properly"
1100 );
1101 }
1102 throw new IllegalStateException("RTP connection has not been initialized yet");
1103 }
1104 if (Arrays.asList(State.PROPOSED, State.PROCEED).contains(current)) {
1105 return Preconditions.checkNotNull(
1106 this.proposedMedia,
1107 "RTP connection has not been initialized properly"
1108 );
1109 }
1110 final RtpContentMap initiatorContentMap = initiatorRtpContentMap;
1111 if (initiatorContentMap != null) {
1112 return initiatorContentMap.getMedia();
1113 } else if (isTerminated()) {
1114 return Collections.emptySet(); //we might fail before we ever got a chance to set media
1115 } else {
1116 return Preconditions.checkNotNull(this.proposedMedia, "RTP connection has not been initialized properly");
1117 }
1118 }
1119
1120
1121 public boolean isVerified() {
1122 final String fingerprint = this.omemoVerification.getFingerprint();
1123 if (fingerprint == null) {
1124 return false;
1125 }
1126 final FingerprintStatus status = id.account.getAxolotlService().getFingerprintTrust(fingerprint);
1127 return status != null && status.isVerified();
1128 }
1129
1130 public synchronized void acceptCall() {
1131 switch (this.state) {
1132 case PROPOSED:
1133 cancelRingingTimeout();
1134 acceptCallFromProposed();
1135 break;
1136 case SESSION_INITIALIZED:
1137 cancelRingingTimeout();
1138 acceptCallFromSessionInitialized();
1139 break;
1140 case ACCEPTED:
1141 Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": the call has already been accepted with another client. UI was just lagging behind");
1142 break;
1143 case PROCEED:
1144 case SESSION_ACCEPTED:
1145 Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": the call has already been accepted. user probably double tapped the UI");
1146 break;
1147 default:
1148 throw new IllegalStateException("Can not accept call from " + this.state);
1149 }
1150 }
1151
1152
1153 public void notifyPhoneCall() {
1154 Log.d(Config.LOGTAG, "a phone call has just been started. killing jingle rtp connections");
1155 if (Arrays.asList(State.PROPOSED, State.SESSION_INITIALIZED).contains(this.state)) {
1156 rejectCall();
1157 } else {
1158 endCall();
1159 }
1160 }
1161
1162 public synchronized void rejectCall() {
1163 if (isTerminated()) {
1164 Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": received rejectCall() when session has already been terminated. nothing to do");
1165 return;
1166 }
1167 switch (this.state) {
1168 case PROPOSED:
1169 rejectCallFromProposed();
1170 break;
1171 case SESSION_INITIALIZED:
1172 rejectCallFromSessionInitiate();
1173 break;
1174 default:
1175 throw new IllegalStateException("Can not reject call from " + this.state);
1176 }
1177 }
1178
1179 public synchronized void endCall() {
1180 if (isTerminated()) {
1181 Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": received endCall() when session has already been terminated. nothing to do");
1182 return;
1183 }
1184 if (isInState(State.PROPOSED) && !isInitiator()) {
1185 rejectCallFromProposed();
1186 return;
1187 }
1188 if (isInState(State.PROCEED)) {
1189 if (isInitiator()) {
1190 retractFromProceed();
1191 } else {
1192 rejectCallFromProceed();
1193 }
1194 return;
1195 }
1196 if (isInitiator() && isInState(State.SESSION_INITIALIZED, State.SESSION_INITIALIZED_PRE_APPROVED)) {
1197 this.webRTCWrapper.close();
1198 sendSessionTerminate(Reason.CANCEL);
1199 return;
1200 }
1201 if (isInState(State.SESSION_INITIALIZED)) {
1202 rejectCallFromSessionInitiate();
1203 return;
1204 }
1205 if (isInState(State.SESSION_INITIALIZED_PRE_APPROVED, State.SESSION_ACCEPTED)) {
1206 this.webRTCWrapper.close();
1207 sendSessionTerminate(Reason.SUCCESS);
1208 return;
1209 }
1210 if (isInState(State.TERMINATED_APPLICATION_FAILURE, State.TERMINATED_CONNECTIVITY_ERROR, State.TERMINATED_DECLINED_OR_BUSY)) {
1211 Log.d(Config.LOGTAG, "ignoring request to end call because already in state " + this.state);
1212 return;
1213 }
1214 throw new IllegalStateException("called 'endCall' while in state " + this.state + ". isInitiator=" + isInitiator());
1215 }
1216
1217 private void retractFromProceed() {
1218 Log.d(Config.LOGTAG, "retract from proceed");
1219 this.sendJingleMessage("retract");
1220 closeTransitionLogFinish(State.RETRACTED_RACED);
1221 }
1222
1223 private void closeTransitionLogFinish(final State state) {
1224 this.webRTCWrapper.close();
1225 transitionOrThrow(state);
1226 writeLogMessage(state);
1227 finish();
1228 }
1229
1230 private void setupWebRTC(final Set<Media> media, final List<PeerConnection.IceServer> iceServers) throws WebRTCWrapper.InitializationException {
1231 this.jingleConnectionManager.ensureConnectionIsRegistered(this);
1232 final AppRTCAudioManager.SpeakerPhonePreference speakerPhonePreference;
1233 if (media.contains(Media.VIDEO)) {
1234 speakerPhonePreference = AppRTCAudioManager.SpeakerPhonePreference.SPEAKER;
1235 } else {
1236 speakerPhonePreference = AppRTCAudioManager.SpeakerPhonePreference.EARPIECE;
1237 }
1238 this.webRTCWrapper.setup(this.xmppConnectionService, speakerPhonePreference);
1239 this.webRTCWrapper.initializePeerConnection(media, iceServers);
1240 }
1241
1242 private void acceptCallFromProposed() {
1243 transitionOrThrow(State.PROCEED);
1244 xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
1245 this.sendJingleMessage("accept", id.account.getJid().asBareJid());
1246 this.sendJingleMessage("proceed");
1247 }
1248
1249 private void rejectCallFromProposed() {
1250 transitionOrThrow(State.REJECTED);
1251 writeLogMessageMissed();
1252 xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
1253 this.sendJingleMessage("reject");
1254 finish();
1255 }
1256
1257 private void rejectCallFromProceed() {
1258 this.sendJingleMessage("reject");
1259 closeTransitionLogFinish(State.REJECTED_RACED);
1260 }
1261
1262 private void rejectCallFromSessionInitiate() {
1263 webRTCWrapper.close();
1264 sendSessionTerminate(Reason.DECLINE);
1265 xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
1266 }
1267
1268 private void sendJingleMessage(final String action) {
1269 sendJingleMessage(action, id.with);
1270 }
1271
1272 private void sendJingleMessage(final String action, final Jid to) {
1273 final MessagePacket messagePacket = new MessagePacket();
1274 messagePacket.setType(MessagePacket.TYPE_CHAT); //we want to carbon copy those
1275 messagePacket.setTo(to);
1276 final Element intent = messagePacket.addChild(action, Namespace.JINGLE_MESSAGE).setAttribute("id", id.sessionId);
1277 if ("proceed".equals(action)) {
1278 messagePacket.setId(JINGLE_MESSAGE_PROCEED_ID_PREFIX + id.sessionId);
1279 if (isOmemoEnabled()) {
1280 final int deviceId = id.account.getAxolotlService().getOwnDeviceId();
1281 final Element device = intent.addChild("device", Namespace.OMEMO_DTLS_SRTP_VERIFICATION);
1282 device.setAttribute("id", deviceId);
1283 }
1284 }
1285 messagePacket.addChild("store", "urn:xmpp:hints");
1286 xmppConnectionService.sendMessagePacket(id.account, messagePacket);
1287 }
1288
1289 private boolean isOmemoEnabled() {
1290 final Conversational conversational = message.getConversation();
1291 if (conversational instanceof Conversation) {
1292 return ((Conversation) conversational).getNextEncryption() == Message.ENCRYPTION_AXOLOTL;
1293 }
1294 return false;
1295 }
1296
1297 private void acceptCallFromSessionInitialized() {
1298 xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
1299 sendSessionAccept();
1300 }
1301
1302 private synchronized boolean isInState(State... state) {
1303 return Arrays.asList(state).contains(this.state);
1304 }
1305
1306 private boolean transition(final State target) {
1307 return transition(target, null);
1308 }
1309
1310 private synchronized boolean transition(final State target, final Runnable runnable) {
1311 final Collection<State> validTransitions = VALID_TRANSITIONS.get(this.state);
1312 if (validTransitions != null && validTransitions.contains(target)) {
1313 this.state = target;
1314 this.stateTransitionException = new StateTransitionException(target);
1315 if (runnable != null) {
1316 runnable.run();
1317 }
1318 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": transitioned into " + target);
1319 updateEndUserState();
1320 updateOngoingCallNotification();
1321 return true;
1322 } else {
1323 return false;
1324 }
1325 }
1326
1327 void transitionOrThrow(final State target) {
1328 if (!transition(target)) {
1329 throw new IllegalStateException(String.format("Unable to transition from %s to %s", this.state, target));
1330 }
1331 }
1332
1333 @Override
1334 public void onIceCandidate(final IceCandidate iceCandidate) {
1335 final IceUdpTransportInfo.Candidate candidate = IceUdpTransportInfo.Candidate.fromSdpAttribute(iceCandidate.sdp);
1336 Log.d(Config.LOGTAG, "sending candidate: " + iceCandidate.toString());
1337 sendTransportInfo(iceCandidate.sdpMid, candidate);
1338 }
1339
1340 @Override
1341 public void onConnectionChange(final PeerConnection.PeerConnectionState newState) {
1342 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": PeerConnectionState changed to " + newState);
1343 if (newState == PeerConnection.PeerConnectionState.CONNECTED && this.rtpConnectionStarted == 0) {
1344 this.rtpConnectionStarted = SystemClock.elapsedRealtime();
1345 }
1346 if (newState == PeerConnection.PeerConnectionState.CLOSED && this.rtpConnectionEnded == 0) {
1347 this.rtpConnectionEnded = SystemClock.elapsedRealtime();
1348 }
1349 //TODO 'failed' means we need to restart ICE
1350 //
1351 //TODO 'disconnected' can probably be ignored as "This is a less stringent test than failed
1352 // and may trigger intermittently and resolve just as spontaneously on less reliable networks,
1353 // or during temporary disconnections. When the problem resolves, the connection may return
1354 // to the connected state."
1355 // Obviously the UI needs to reflect this new state with a 'reconnecting' display or something
1356 if (Arrays.asList(PeerConnection.PeerConnectionState.FAILED, PeerConnection.PeerConnectionState.DISCONNECTED).contains(newState)) {
1357 if (isTerminated()) {
1358 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": not sending session-terminate after connectivity error because session is already in state " + this.state);
1359 return;
1360 }
1361 new Thread(this::closeWebRTCSessionAfterFailedConnection).start();
1362 } else {
1363 updateEndUserState();
1364 }
1365 }
1366
1367 private void closeWebRTCSessionAfterFailedConnection() {
1368 this.webRTCWrapper.close();
1369 synchronized (this) {
1370 if (isTerminated()) {
1371 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": no need to send session-terminate after failed connection. Other party already did");
1372 return;
1373 }
1374 sendSessionTerminate(Reason.CONNECTIVITY_ERROR);
1375 }
1376 }
1377
1378 public long getRtpConnectionStarted() {
1379 return this.rtpConnectionStarted;
1380 }
1381
1382 public long getRtpConnectionEnded() {
1383 return this.rtpConnectionEnded;
1384 }
1385
1386 public AppRTCAudioManager getAudioManager() {
1387 return webRTCWrapper.getAudioManager();
1388 }
1389
1390 public boolean isMicrophoneEnabled() {
1391 return webRTCWrapper.isMicrophoneEnabled();
1392 }
1393
1394 public boolean setMicrophoneEnabled(final boolean enabled) {
1395 return webRTCWrapper.setMicrophoneEnabled(enabled);
1396 }
1397
1398 public boolean isVideoEnabled() {
1399 return webRTCWrapper.isVideoEnabled();
1400 }
1401
1402 public void setVideoEnabled(final boolean enabled) {
1403 webRTCWrapper.setVideoEnabled(enabled);
1404 }
1405
1406 public boolean isCameraSwitchable() {
1407 return webRTCWrapper.isCameraSwitchable();
1408 }
1409
1410 public boolean isFrontCamera() {
1411 return webRTCWrapper.isFrontCamera();
1412 }
1413
1414 public ListenableFuture<Boolean> switchCamera() {
1415 return webRTCWrapper.switchCamera();
1416 }
1417
1418 @Override
1419 public void onAudioDeviceChanged(AppRTCAudioManager.AudioDevice selectedAudioDevice, Set<AppRTCAudioManager.AudioDevice> availableAudioDevices) {
1420 xmppConnectionService.notifyJingleRtpConnectionUpdate(selectedAudioDevice, availableAudioDevices);
1421 }
1422
1423 private void updateEndUserState() {
1424 final RtpEndUserState endUserState = getEndUserState();
1425 jingleConnectionManager.toneManager.transition(isInitiator(), endUserState, getMedia());
1426 xmppConnectionService.notifyJingleRtpConnectionUpdate(id.account, id.with, id.sessionId, endUserState);
1427 }
1428
1429 private void updateOngoingCallNotification() {
1430 if (STATES_SHOWING_ONGOING_CALL.contains(this.state)) {
1431 xmppConnectionService.setOngoingCall(id, getMedia());
1432 } else {
1433 xmppConnectionService.removeOngoingCall();
1434 }
1435 }
1436
1437 private void discoverIceServers(final OnIceServersDiscovered onIceServersDiscovered) {
1438 if (id.account.getXmppConnection().getFeatures().externalServiceDiscovery()) {
1439 final IqPacket request = new IqPacket(IqPacket.TYPE.GET);
1440 request.setTo(id.account.getDomain());
1441 request.addChild("services", Namespace.EXTERNAL_SERVICE_DISCOVERY);
1442 xmppConnectionService.sendIqPacket(id.account, request, (account, response) -> {
1443 ImmutableList.Builder<PeerConnection.IceServer> listBuilder = new ImmutableList.Builder<>();
1444 if (response.getType() == IqPacket.TYPE.RESULT) {
1445 final Element services = response.findChild("services", Namespace.EXTERNAL_SERVICE_DISCOVERY);
1446 final List<Element> children = services == null ? Collections.emptyList() : services.getChildren();
1447 for (final Element child : children) {
1448 if ("service".equals(child.getName())) {
1449 final String type = child.getAttribute("type");
1450 final String host = child.getAttribute("host");
1451 final String sport = child.getAttribute("port");
1452 final Integer port = sport == null ? null : Ints.tryParse(sport);
1453 final String transport = child.getAttribute("transport");
1454 final String username = child.getAttribute("username");
1455 final String password = child.getAttribute("password");
1456 if (Strings.isNullOrEmpty(host) || port == null) {
1457 continue;
1458 }
1459 if (port < 0 || port > 65535) {
1460 continue;
1461 }
1462 if (Arrays.asList("stun", "stuns", "turn", "turns").contains(type) && Arrays.asList("udp", "tcp").contains(transport)) {
1463 if (Arrays.asList("stuns", "turns").contains(type) && "udp".equals(transport)) {
1464 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": skipping invalid combination of udp/tls in external services");
1465 continue;
1466 }
1467 final PeerConnection.IceServer.Builder iceServerBuilder = PeerConnection.IceServer
1468 .builder(String.format("%s:%s:%s?transport=%s", type, IP.wrapIPv6(host), port, transport));
1469 iceServerBuilder.setTlsCertPolicy(PeerConnection.TlsCertPolicy.TLS_CERT_POLICY_INSECURE_NO_CHECK);
1470 if (username != null && password != null) {
1471 iceServerBuilder.setUsername(username);
1472 iceServerBuilder.setPassword(password);
1473 } else if (Arrays.asList("turn", "turns").contains(type)) {
1474 //The WebRTC spec requires throwing an InvalidAccessError when username (from libwebrtc source coder)
1475 //https://chromium.googlesource.com/external/webrtc/+/master/pc/ice_server_parsing.cc
1476 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": skipping " + type + "/" + transport + " without username and password");
1477 continue;
1478 }
1479 final PeerConnection.IceServer iceServer = iceServerBuilder.createIceServer();
1480 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": discovered ICE Server: " + iceServer);
1481 listBuilder.add(iceServer);
1482 }
1483 }
1484 }
1485 }
1486 final List<PeerConnection.IceServer> iceServers = listBuilder.build();
1487 if (iceServers.size() == 0) {
1488 Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": no ICE server found " + response);
1489 }
1490 onIceServersDiscovered.onIceServersDiscovered(iceServers);
1491 });
1492 } else {
1493 Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": has no external service discovery");
1494 onIceServersDiscovered.onIceServersDiscovered(Collections.emptyList());
1495 }
1496 }
1497
1498 private void finish() {
1499 if (isTerminated()) {
1500 this.cancelRingingTimeout();
1501 this.webRTCWrapper.verifyClosed();
1502 this.jingleConnectionManager.setTerminalSessionState(id, getEndUserState(), getMedia());
1503 this.jingleConnectionManager.finishConnectionOrThrow(this);
1504 } else {
1505 throw new IllegalStateException(String.format("Unable to call finish from %s", this.state));
1506 }
1507 }
1508
1509 private void writeLogMessage(final State state) {
1510 final long started = this.rtpConnectionStarted;
1511 long duration = started <= 0 ? 0 : SystemClock.elapsedRealtime() - started;
1512 if (state == State.TERMINATED_SUCCESS || (state == State.TERMINATED_CONNECTIVITY_ERROR && duration > 0)) {
1513 writeLogMessageSuccess(duration);
1514 } else {
1515 writeLogMessageMissed();
1516 }
1517 }
1518
1519 private void writeLogMessageSuccess(final long duration) {
1520 this.message.setBody(new RtpSessionStatus(true, duration).toString());
1521 this.writeMessage();
1522 }
1523
1524 private void writeLogMessageMissed() {
1525 this.message.setBody(new RtpSessionStatus(false, 0).toString());
1526 this.writeMessage();
1527 }
1528
1529 private void writeMessage() {
1530 final Conversational conversational = message.getConversation();
1531 if (conversational instanceof Conversation) {
1532 ((Conversation) conversational).add(this.message);
1533 xmppConnectionService.createMessageAsync(message);
1534 xmppConnectionService.updateConversationUi();
1535 } else {
1536 throw new IllegalStateException("Somehow the conversation in a message was a stub");
1537 }
1538 }
1539
1540 public State getState() {
1541 return this.state;
1542 }
1543
1544 boolean isTerminated() {
1545 return TERMINATED.contains(this.state);
1546 }
1547
1548 public Optional<VideoTrack> getLocalVideoTrack() {
1549 return webRTCWrapper.getLocalVideoTrack();
1550 }
1551
1552 public Optional<VideoTrack> getRemoteVideoTrack() {
1553 return webRTCWrapper.getRemoteVideoTrack();
1554 }
1555
1556
1557 public EglBase.Context getEglBaseContext() {
1558 return webRTCWrapper.getEglBaseContext();
1559 }
1560
1561 void setProposedMedia(final Set<Media> media) {
1562 this.proposedMedia = media;
1563 }
1564
1565 public void fireStateUpdate() {
1566 final RtpEndUserState endUserState = getEndUserState();
1567 xmppConnectionService.notifyJingleRtpConnectionUpdate(id.account, id.with, id.sessionId, endUserState);
1568 }
1569
1570 private interface OnIceServersDiscovered {
1571 void onIceServersDiscovered(List<PeerConnection.IceServer> iceServers);
1572 }
1573
1574 private static class StateTransitionException extends Exception {
1575 private final State state;
1576
1577 private StateTransitionException(final State state) {
1578 this.state = state;
1579 }
1580 }
1581}