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