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