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