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