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