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