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