1package eu.siacs.conversations.xmpp.jingle;
2
3import android.util.Log;
4import android.os.Environment;
5
6import androidx.annotation.NonNull;
7import androidx.annotation.Nullable;
8
9import com.google.common.base.Joiner;
10import com.google.common.base.Optional;
11import com.google.common.base.Preconditions;
12import com.google.common.base.Stopwatch;
13import com.google.common.base.Strings;
14import com.google.common.base.Throwables;
15import com.google.common.collect.Collections2;
16import com.google.common.collect.ImmutableList;
17import com.google.common.collect.ImmutableMap;
18import com.google.common.collect.ImmutableSet;
19import com.google.common.collect.Sets;
20import com.google.common.primitives.Ints;
21import com.google.common.util.concurrent.FutureCallback;
22import com.google.common.util.concurrent.Futures;
23import com.google.common.util.concurrent.ListenableFuture;
24import com.google.common.util.concurrent.MoreExecutors;
25
26import org.webrtc.DtmfSender;
27import org.webrtc.EglBase;
28import org.webrtc.IceCandidate;
29import org.webrtc.PeerConnection;
30import org.webrtc.VideoTrack;
31
32import java.io.File;
33import java.io.IOException;
34import java.util.Arrays;
35import java.util.Collection;
36import java.util.Collections;
37import java.util.LinkedList;
38import java.util.List;
39import java.util.Map;
40import java.util.Queue;
41import java.util.Set;
42import java.util.concurrent.ExecutionException;
43import java.util.concurrent.ScheduledFuture;
44import java.util.concurrent.TimeUnit;
45
46import eu.siacs.conversations.BuildConfig;
47import eu.siacs.conversations.Config;
48import eu.siacs.conversations.crypto.axolotl.AxolotlService;
49import eu.siacs.conversations.crypto.axolotl.CryptoFailedException;
50import eu.siacs.conversations.crypto.axolotl.FingerprintStatus;
51import eu.siacs.conversations.entities.Account;
52import eu.siacs.conversations.entities.Conversation;
53import eu.siacs.conversations.entities.Conversational;
54import eu.siacs.conversations.entities.Message;
55import eu.siacs.conversations.entities.RtpSessionStatus;
56import eu.siacs.conversations.services.AppRTCAudioManager;
57import eu.siacs.conversations.utils.IP;
58import eu.siacs.conversations.xml.Element;
59import eu.siacs.conversations.xml.Namespace;
60import eu.siacs.conversations.xmpp.Jid;
61import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
62import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
63import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
64import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
65import eu.siacs.conversations.xmpp.jingle.stanzas.Proceed;
66import eu.siacs.conversations.xmpp.jingle.stanzas.Propose;
67import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
68import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
69import eu.siacs.conversations.xmpp.stanzas.IqPacket;
70import eu.siacs.conversations.xmpp.stanzas.MessagePacket;
71
72public class JingleRtpConnection extends AbstractJingleConnection
73 implements WebRTCWrapper.EventCallback {
74
75 public static final List<State> STATES_SHOWING_ONGOING_CALL =
76 Arrays.asList(
77 State.PROCEED, State.SESSION_INITIALIZED_PRE_APPROVED, State.SESSION_ACCEPTED);
78 private static final long BUSY_TIME_OUT = 30;
79 private static final List<State> TERMINATED =
80 Arrays.asList(
81 State.ACCEPTED,
82 State.REJECTED,
83 State.REJECTED_RACED,
84 State.RETRACTED,
85 State.RETRACTED_RACED,
86 State.TERMINATED_SUCCESS,
87 State.TERMINATED_DECLINED_OR_BUSY,
88 State.TERMINATED_CONNECTIVITY_ERROR,
89 State.TERMINATED_CANCEL_OR_TIMEOUT,
90 State.TERMINATED_APPLICATION_FAILURE,
91 State.TERMINATED_SECURITY_ERROR);
92
93 private static final Map<State, Collection<State>> VALID_TRANSITIONS;
94
95 static {
96 final ImmutableMap.Builder<State, Collection<State>> transitionBuilder =
97 new ImmutableMap.Builder<>();
98 transitionBuilder.put(
99 State.NULL,
100 ImmutableList.of(
101 State.PROPOSED,
102 State.SESSION_INITIALIZED,
103 State.TERMINATED_APPLICATION_FAILURE,
104 State.TERMINATED_SECURITY_ERROR));
105 transitionBuilder.put(
106 State.PROPOSED,
107 ImmutableList.of(
108 State.ACCEPTED,
109 State.PROCEED,
110 State.REJECTED,
111 State.RETRACTED,
112 State.TERMINATED_APPLICATION_FAILURE,
113 State.TERMINATED_SECURITY_ERROR,
114 State.TERMINATED_CONNECTIVITY_ERROR // only used when the xmpp connection
115 // rebinds
116 ));
117 transitionBuilder.put(
118 State.PROCEED,
119 ImmutableList.of(
120 State.REJECTED_RACED,
121 State.RETRACTED_RACED,
122 State.SESSION_INITIALIZED_PRE_APPROVED,
123 State.TERMINATED_SUCCESS,
124 State.TERMINATED_APPLICATION_FAILURE,
125 State.TERMINATED_SECURITY_ERROR,
126 State.TERMINATED_CONNECTIVITY_ERROR // at this state used for error
127 // bounces of the proceed message
128 ));
129 transitionBuilder.put(
130 State.SESSION_INITIALIZED,
131 ImmutableList.of(
132 State.SESSION_ACCEPTED,
133 State.TERMINATED_SUCCESS,
134 State.TERMINATED_DECLINED_OR_BUSY,
135 State.TERMINATED_CONNECTIVITY_ERROR, // at this state used for IQ errors
136 // and IQ timeouts
137 State.TERMINATED_CANCEL_OR_TIMEOUT,
138 State.TERMINATED_APPLICATION_FAILURE,
139 State.TERMINATED_SECURITY_ERROR));
140 transitionBuilder.put(
141 State.SESSION_INITIALIZED_PRE_APPROVED,
142 ImmutableList.of(
143 State.SESSION_ACCEPTED,
144 State.TERMINATED_SUCCESS,
145 State.TERMINATED_DECLINED_OR_BUSY,
146 State.TERMINATED_CONNECTIVITY_ERROR, // at this state used for IQ errors
147 // and IQ timeouts
148 State.TERMINATED_CANCEL_OR_TIMEOUT,
149 State.TERMINATED_APPLICATION_FAILURE,
150 State.TERMINATED_SECURITY_ERROR));
151 transitionBuilder.put(
152 State.SESSION_ACCEPTED,
153 ImmutableList.of(
154 State.TERMINATED_SUCCESS,
155 State.TERMINATED_DECLINED_OR_BUSY,
156 State.TERMINATED_CONNECTIVITY_ERROR,
157 State.TERMINATED_CANCEL_OR_TIMEOUT,
158 State.TERMINATED_APPLICATION_FAILURE,
159 State.TERMINATED_SECURITY_ERROR));
160 VALID_TRANSITIONS = transitionBuilder.build();
161 }
162
163 private final WebRTCWrapper webRTCWrapper = new WebRTCWrapper(this);
164 private final Queue<Map.Entry<String, RtpContentMap.DescriptionTransport>>
165 pendingIceCandidates = new LinkedList<>();
166 private final OmemoVerification omemoVerification = new OmemoVerification();
167 private final Message message;
168 private State state = State.NULL;
169 private Set<Media> proposedMedia;
170 private RtpContentMap initiatorRtpContentMap;
171 private RtpContentMap responderRtpContentMap;
172 private RtpContentMap incomingContentAdd;
173 private RtpContentMap outgoingContentAdd;
174 private IceUdpTransportInfo.Setup peerDtlsSetup;
175 private final Stopwatch sessionDuration = Stopwatch.createUnstarted();
176 private final Queue<PeerConnection.PeerConnectionState> stateHistory = new LinkedList<>();
177 private ScheduledFuture<?> ringingTimeoutFuture;
178 private final long created = System.currentTimeMillis() / 1000L;
179
180 JingleRtpConnection(JingleConnectionManager jingleConnectionManager, Id id, Jid initiator) {
181 super(jingleConnectionManager, id, initiator);
182 final Conversation conversation =
183 jingleConnectionManager
184 .getXmppConnectionService()
185 .findOrCreateConversation(id.account, id.with.asBareJid(), false, false);
186 this.message =
187 new Message(
188 conversation,
189 isInitiator() ? Message.STATUS_SEND : Message.STATUS_RECEIVED,
190 Message.TYPE_RTP_SESSION,
191 id.sessionId);
192 }
193
194 private static State reasonToState(Reason reason) {
195 switch (reason) {
196 case SUCCESS:
197 return State.TERMINATED_SUCCESS;
198 case DECLINE:
199 case BUSY:
200 return State.TERMINATED_DECLINED_OR_BUSY;
201 case CANCEL:
202 case TIMEOUT:
203 return State.TERMINATED_CANCEL_OR_TIMEOUT;
204 case SECURITY_ERROR:
205 return State.TERMINATED_SECURITY_ERROR;
206 case FAILED_APPLICATION:
207 case UNSUPPORTED_TRANSPORTS:
208 case UNSUPPORTED_APPLICATIONS:
209 return State.TERMINATED_APPLICATION_FAILURE;
210 default:
211 return State.TERMINATED_CONNECTIVITY_ERROR;
212 }
213 }
214
215 @Override
216 synchronized void deliverPacket(final JinglePacket jinglePacket) {
217 switch (jinglePacket.getAction()) {
218 case SESSION_INITIATE:
219 receiveSessionInitiate(jinglePacket);
220 break;
221 case TRANSPORT_INFO:
222 receiveTransportInfo(jinglePacket);
223 break;
224 case SESSION_ACCEPT:
225 receiveSessionAccept(jinglePacket);
226 break;
227 case SESSION_TERMINATE:
228 receiveSessionTerminate(jinglePacket);
229 break;
230 case CONTENT_ADD:
231 receiveContentAdd(jinglePacket);
232 break;
233 case CONTENT_ACCEPT:
234 receiveContentAccept(jinglePacket);
235 break;
236 case CONTENT_REJECT:
237 receiveContentReject(jinglePacket);
238 break;
239 case CONTENT_REMOVE:
240 receiveContentRemove(jinglePacket);
241 break;
242 default:
243 respondOk(jinglePacket);
244 Log.d(
245 Config.LOGTAG,
246 String.format(
247 "%s: received unhandled jingle action %s",
248 id.account.getJid().asBareJid(), jinglePacket.getAction()));
249 break;
250 }
251 }
252
253 @Override
254 synchronized void notifyRebound() {
255 if (isTerminated()) {
256 return;
257 }
258 webRTCWrapper.close();
259 if (!isInitiator() && isInState(State.PROPOSED, State.SESSION_INITIALIZED)) {
260 xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
261 }
262 if (isInState(
263 State.SESSION_INITIALIZED,
264 State.SESSION_INITIALIZED_PRE_APPROVED,
265 State.SESSION_ACCEPTED)) {
266 // we might have already changed resources (full jid) at this point; so this might not
267 // even reach the other party
268 sendSessionTerminate(Reason.CONNECTIVITY_ERROR);
269 } else {
270 transitionOrThrow(State.TERMINATED_CONNECTIVITY_ERROR);
271 finish();
272 }
273 }
274
275 public boolean applyDtmfTone(String tone) {
276 return webRTCWrapper.applyDtmfTone(tone);
277 }
278
279 private void receiveSessionTerminate(final JinglePacket jinglePacket) {
280 respondOk(jinglePacket);
281 final JinglePacket.ReasonWrapper wrapper = jinglePacket.getReason();
282 final State previous = this.state;
283 Log.d(
284 Config.LOGTAG,
285 id.account.getJid().asBareJid()
286 + ": received session terminate reason="
287 + wrapper.reason
288 + "("
289 + Strings.nullToEmpty(wrapper.text)
290 + ") while in state "
291 + previous);
292 if (TERMINATED.contains(previous)) {
293 Log.d(
294 Config.LOGTAG,
295 id.account.getJid().asBareJid()
296 + ": ignoring session terminate because already in "
297 + previous);
298 return;
299 }
300 webRTCWrapper.close();
301 final State target = reasonToState(wrapper.reason);
302 transitionOrThrow(target);
303 writeLogMessage(target);
304 if (previous == State.PROPOSED || previous == State.SESSION_INITIALIZED) {
305 xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
306 }
307 finish();
308 }
309
310 private void receiveTransportInfo(final JinglePacket jinglePacket) {
311 // Due to the asynchronicity of processing session-init we might move from NULL|PROCEED to
312 // INITIALIZED only after transport-info has been received
313 if (isInState(
314 State.NULL,
315 State.PROCEED,
316 State.SESSION_INITIALIZED,
317 State.SESSION_INITIALIZED_PRE_APPROVED,
318 State.SESSION_ACCEPTED)) {
319 final RtpContentMap contentMap;
320 try {
321 contentMap = RtpContentMap.of(jinglePacket);
322 } catch (final IllegalArgumentException | NullPointerException e) {
323 Log.d(
324 Config.LOGTAG,
325 id.account.getJid().asBareJid()
326 + ": improperly formatted contents; ignoring",
327 e);
328 respondOk(jinglePacket);
329 return;
330 }
331 receiveTransportInfo(jinglePacket, contentMap);
332 } else {
333 if (isTerminated()) {
334 respondOk(jinglePacket);
335 Log.d(
336 Config.LOGTAG,
337 id.account.getJid().asBareJid()
338 + ": ignoring out-of-order transport info; we where already terminated");
339 } else {
340 Log.d(
341 Config.LOGTAG,
342 id.account.getJid().asBareJid()
343 + ": received transport info while in state="
344 + this.state);
345 terminateWithOutOfOrder(jinglePacket);
346 }
347 }
348 }
349
350 private void receiveTransportInfo(
351 final JinglePacket jinglePacket, final RtpContentMap contentMap) {
352 final Set<Map.Entry<String, RtpContentMap.DescriptionTransport>> candidates =
353 contentMap.contents.entrySet();
354 if (this.state == State.SESSION_ACCEPTED) {
355 // zero candidates + modified credentials are an ICE restart offer
356 if (checkForIceRestart(jinglePacket, contentMap)) {
357 return;
358 }
359 respondOk(jinglePacket);
360 try {
361 processCandidates(candidates);
362 } catch (final WebRTCWrapper.PeerConnectionNotInitialized e) {
363 Log.w(
364 Config.LOGTAG,
365 id.account.getJid().asBareJid()
366 + ": PeerConnection was not initialized when processing transport info. this usually indicates a race condition that can be ignored");
367 }
368 } else {
369 respondOk(jinglePacket);
370 pendingIceCandidates.addAll(candidates);
371 }
372 }
373
374 private void receiveContentAdd(final JinglePacket jinglePacket) {
375 final RtpContentMap modification;
376 try {
377 modification = RtpContentMap.of(jinglePacket);
378 modification.requireContentDescriptions();
379 } catch (final RuntimeException e) {
380 Log.d(
381 Config.LOGTAG,
382 id.getAccount().getJid().asBareJid() + ": improperly formatted contents",
383 Throwables.getRootCause(e));
384 respondOk(jinglePacket);
385 webRTCWrapper.close();
386 sendSessionTerminate(Reason.of(e), e.getMessage());
387 return;
388 }
389 if (isInState(State.SESSION_ACCEPTED)) {
390 receiveContentAdd(jinglePacket, modification);
391 } else {
392 terminateWithOutOfOrder(jinglePacket);
393 }
394 }
395
396 private void receiveContentAdd(
397 final JinglePacket jinglePacket, final RtpContentMap modification) {
398 final RtpContentMap remote = getRemoteContentMap();
399 if (!Collections.disjoint(modification.getNames(), remote.getNames())) {
400 respondOk(jinglePacket);
401 this.webRTCWrapper.close();
402 sendSessionTerminate(
403 Reason.FAILED_APPLICATION,
404 String.format(
405 "contents with names %s already exists",
406 Joiner.on(", ").join(modification.getNames())));
407 return;
408 }
409 final ContentAddition contentAddition =
410 ContentAddition.of(ContentAddition.Direction.INCOMING, modification);
411
412 final RtpContentMap outgoing = this.outgoingContentAdd;
413 final Set<ContentAddition.Summary> outgoingContentAddSummary =
414 outgoing == null ? Collections.emptySet() : ContentAddition.summary(outgoing);
415
416 if (outgoingContentAddSummary.equals(contentAddition.summary)) {
417 if (isInitiator()) {
418 Log.d(
419 Config.LOGTAG,
420 id.getAccount().getJid().asBareJid()
421 + ": respond with tie break to matching content-add offer");
422 respondWithTieBreak(jinglePacket);
423 } else {
424 Log.d(
425 Config.LOGTAG,
426 id.getAccount().getJid().asBareJid()
427 + ": automatically accept matching content-add offer");
428 acceptContentAdd(contentAddition.summary, modification);
429 }
430 return;
431 }
432
433 // once we can display multiple video tracks we can be more loose with this condition
434 // theoretically it should also be fine to automatically accept audio only contents
435 if (Media.audioOnly(remote.getMedia()) && Media.videoOnly(contentAddition.media())) {
436 Log.d(
437 Config.LOGTAG,
438 id.getAccount().getJid().asBareJid() + ": received " + contentAddition);
439 this.incomingContentAdd = modification;
440 respondOk(jinglePacket);
441 updateEndUserState();
442 } else {
443 respondOk(jinglePacket);
444 // TODO do we want to add a reason?
445 rejectContentAdd(modification);
446 }
447 }
448
449 private void receiveContentAccept(final JinglePacket jinglePacket) {
450 final RtpContentMap receivedContentAccept;
451 try {
452 receivedContentAccept = RtpContentMap.of(jinglePacket);
453 receivedContentAccept.requireContentDescriptions();
454 } catch (final RuntimeException e) {
455 Log.d(
456 Config.LOGTAG,
457 id.getAccount().getJid().asBareJid() + ": improperly formatted contents",
458 Throwables.getRootCause(e));
459 respondOk(jinglePacket);
460 webRTCWrapper.close();
461 sendSessionTerminate(Reason.of(e), e.getMessage());
462 return;
463 }
464
465 final RtpContentMap outgoingContentAdd = this.outgoingContentAdd;
466 if (outgoingContentAdd == null) {
467 Log.d(Config.LOGTAG, "received content-accept when we had no outgoing content add");
468 terminateWithOutOfOrder(jinglePacket);
469 return;
470 }
471 final Set<ContentAddition.Summary> ourSummary = ContentAddition.summary(outgoingContentAdd);
472 if (ourSummary.equals(ContentAddition.summary(receivedContentAccept))) {
473 this.outgoingContentAdd = null;
474 respondOk(jinglePacket);
475 receiveContentAccept(receivedContentAccept);
476 } else {
477 Log.d(Config.LOGTAG, "received content-accept did not match our outgoing content-add");
478 terminateWithOutOfOrder(jinglePacket);
479 }
480 }
481
482 private void receiveContentAccept(final RtpContentMap receivedContentAccept) {
483 final IceUdpTransportInfo.Setup peerDtlsSetup = getPeerDtlsSetup();
484 final RtpContentMap modifiedContentMap =
485 getRemoteContentMap().addContent(receivedContentAccept, peerDtlsSetup);
486
487 setRemoteContentMap(modifiedContentMap);
488
489 final SessionDescription answer = SessionDescription.of(modifiedContentMap, !isInitiator());
490
491 final org.webrtc.SessionDescription sdp =
492 new org.webrtc.SessionDescription(
493 org.webrtc.SessionDescription.Type.ANSWER, answer.toString());
494
495 try {
496 this.webRTCWrapper.setRemoteDescription(sdp).get();
497 } catch (final Exception e) {
498 final Throwable cause = Throwables.getRootCause(e);
499 Log.d(
500 Config.LOGTAG,
501 id.getAccount().getJid().asBareJid()
502 + ": unable to set remote description after receiving content-accept",
503 cause);
504 webRTCWrapper.close();
505 sendSessionTerminate(Reason.FAILED_APPLICATION, cause.getMessage());
506 return;
507 }
508 updateEndUserState();
509 Log.d(
510 Config.LOGTAG,
511 id.getAccount().getJid().asBareJid()
512 + ": remote has accepted content-add "
513 + ContentAddition.summary(receivedContentAccept));
514 }
515
516 private void receiveContentReject(final JinglePacket jinglePacket) {
517 final RtpContentMap receivedContentReject;
518 try {
519 receivedContentReject = RtpContentMap.of(jinglePacket);
520 } catch (final RuntimeException e) {
521 Log.d(
522 Config.LOGTAG,
523 id.getAccount().getJid().asBareJid() + ": improperly formatted contents",
524 Throwables.getRootCause(e));
525 respondOk(jinglePacket);
526 this.webRTCWrapper.close();
527 sendSessionTerminate(Reason.of(e), e.getMessage());
528 return;
529 }
530
531 final RtpContentMap outgoingContentAdd = this.outgoingContentAdd;
532 if (outgoingContentAdd == null) {
533 Log.d(Config.LOGTAG, "received content-reject when we had no outgoing content add");
534 terminateWithOutOfOrder(jinglePacket);
535 return;
536 }
537 final Set<ContentAddition.Summary> ourSummary = ContentAddition.summary(outgoingContentAdd);
538 if (ourSummary.equals(ContentAddition.summary(receivedContentReject))) {
539 this.outgoingContentAdd = null;
540 respondOk(jinglePacket);
541 Log.d(Config.LOGTAG,jinglePacket.toString());
542 receiveContentReject(ourSummary);
543 } else {
544 Log.d(Config.LOGTAG, "received content-reject did not match our outgoing content-add");
545 terminateWithOutOfOrder(jinglePacket);
546 }
547 }
548
549 private void receiveContentReject(final Set<ContentAddition.Summary> summary) {
550 try {
551 this.webRTCWrapper.removeTrack(Media.VIDEO);
552 final RtpContentMap localContentMap = customRollback();
553 modifyLocalContentMap(localContentMap);
554 } catch (final Exception e) {
555 final Throwable cause = Throwables.getRootCause(e);
556 Log.d(
557 Config.LOGTAG,
558 id.getAccount().getJid().asBareJid()
559 + ": unable to rollback local description after receiving content-reject",
560 cause);
561 webRTCWrapper.close();
562 sendSessionTerminate(Reason.FAILED_APPLICATION, cause.getMessage());
563 return;
564 }
565 Log.d(
566 Config.LOGTAG,
567 id.getAccount().getJid().asBareJid()
568 + ": remote has rejected our content-add "
569 + summary);
570 }
571
572 private void receiveContentRemove(final JinglePacket jinglePacket) {
573 final RtpContentMap receivedContentRemove;
574 try {
575 receivedContentRemove = RtpContentMap.of(jinglePacket);
576 receivedContentRemove.requireContentDescriptions();
577 } catch (final RuntimeException e) {
578 Log.d(
579 Config.LOGTAG,
580 id.getAccount().getJid().asBareJid() + ": improperly formatted contents",
581 Throwables.getRootCause(e));
582 respondOk(jinglePacket);
583 this.webRTCWrapper.close();
584 sendSessionTerminate(Reason.of(e), e.getMessage());
585 return;
586 }
587 respondOk(jinglePacket);
588 receiveContentRemove(receivedContentRemove);
589 }
590
591 private void receiveContentRemove(final RtpContentMap receivedContentRemove) {
592 final RtpContentMap incomingContentAdd = this.incomingContentAdd;
593 final Set<ContentAddition.Summary> contentAddSummary =
594 incomingContentAdd == null
595 ? Collections.emptySet()
596 : ContentAddition.summary(incomingContentAdd);
597 final Set<ContentAddition.Summary> removeSummary =
598 ContentAddition.summary(receivedContentRemove);
599 if (contentAddSummary.equals(removeSummary)) {
600 this.incomingContentAdd = null;
601 updateEndUserState();
602 } else {
603 webRTCWrapper.close();
604 sendSessionTerminate(
605 Reason.FAILED_APPLICATION,
606 String.format(
607 "%s only supports %s as a means to retract a not yet accepted %s",
608 BuildConfig.APP_NAME,
609 JinglePacket.Action.CONTENT_REMOVE,
610 JinglePacket.Action.CONTENT_ACCEPT));
611 }
612 }
613
614 public synchronized void retractContentAdd() {
615 final RtpContentMap outgoingContentAdd = this.outgoingContentAdd;
616 if (outgoingContentAdd == null) {
617 throw new IllegalStateException("Not outgoing content add");
618 }
619 try {
620 webRTCWrapper.removeTrack(Media.VIDEO);
621 final RtpContentMap localContentMap = customRollback();
622 modifyLocalContentMap(localContentMap);
623 } catch (final Exception e) {
624 final Throwable cause = Throwables.getRootCause(e);
625 Log.d(
626 Config.LOGTAG,
627 id.getAccount().getJid().asBareJid()
628 + ": unable to rollback local description after trying to retract content-add",
629 cause);
630 webRTCWrapper.close();
631 sendSessionTerminate(Reason.FAILED_APPLICATION, cause.getMessage());
632 return;
633 }
634 this.outgoingContentAdd = null;
635 final JinglePacket retract =
636 outgoingContentAdd
637 .toStub()
638 .toJinglePacket(JinglePacket.Action.CONTENT_REMOVE, id.sessionId);
639 this.send(retract);
640 Log.d(
641 Config.LOGTAG,
642 id.getAccount().getJid()
643 + ": retract content-add "
644 + ContentAddition.summary(outgoingContentAdd));
645 }
646
647 private RtpContentMap customRollback() throws ExecutionException, InterruptedException {
648 final SessionDescription sdp = setLocalSessionDescription();
649 final RtpContentMap localRtpContentMap = RtpContentMap.of(sdp, isInitiator());
650 final SessionDescription answer = generateFakeResponse(localRtpContentMap);
651 this.webRTCWrapper
652 .setRemoteDescription(
653 new org.webrtc.SessionDescription(
654 org.webrtc.SessionDescription.Type.ANSWER, answer.toString()))
655 .get();
656 return localRtpContentMap;
657 }
658
659 private SessionDescription generateFakeResponse(final RtpContentMap localContentMap) {
660 final RtpContentMap currentRemote = getRemoteContentMap();
661 final RtpContentMap.Diff diff = currentRemote.diff(localContentMap);
662 if (diff.isEmpty()) {
663 throw new IllegalStateException(
664 "Unexpected rollback condition. No difference between local and remote");
665 }
666 final RtpContentMap patch = localContentMap.toContentModification(diff.added);
667 if (ImmutableSet.of(Content.Senders.NONE).equals(patch.getSenders())) {
668 final RtpContentMap nextRemote =
669 currentRemote.addContent(
670 patch.modifiedSenders(Content.Senders.NONE), getPeerDtlsSetup());
671 return SessionDescription.of(nextRemote, !isInitiator());
672 }
673 throw new IllegalStateException(
674 "Unexpected rollback condition. Senders were not uniformly none");
675 }
676
677 public synchronized void acceptContentAdd(@NonNull final Set<ContentAddition.Summary> contentAddition) {
678 final RtpContentMap incomingContentAdd = this.incomingContentAdd;
679 if (incomingContentAdd == null) {
680 throw new IllegalStateException("No incoming content add");
681 }
682
683 if (contentAddition.equals(ContentAddition.summary(incomingContentAdd))) {
684 this.incomingContentAdd = null;
685 acceptContentAdd(contentAddition, incomingContentAdd);
686 } else {
687 throw new IllegalStateException("Accepted content add does not match pending content-add");
688 }
689 }
690
691 private void acceptContentAdd(@NonNull final Set<ContentAddition.Summary> contentAddition, final RtpContentMap incomingContentAdd) {
692 final IceUdpTransportInfo.Setup setup = getPeerDtlsSetup();
693 final RtpContentMap modifiedContentMap = getRemoteContentMap().addContent(incomingContentAdd, setup);
694 this.setRemoteContentMap(modifiedContentMap);
695
696 final SessionDescription offer;
697 try {
698 offer = SessionDescription.of(modifiedContentMap, !isInitiator());
699 } catch (final IllegalArgumentException | NullPointerException e) {
700 Log.d(Config.LOGTAG, id.getAccount().getJid().asBareJid() + ": unable convert offer from content-add to SDP", e);
701 webRTCWrapper.close();
702 sendSessionTerminate(Reason.FAILED_APPLICATION, e.getMessage());
703 return;
704 }
705 this.incomingContentAdd = null;
706 acceptContentAdd(contentAddition, offer);
707 }
708
709 private void acceptContentAdd(
710 final Set<ContentAddition.Summary> contentAddition, final SessionDescription offer) {
711 final org.webrtc.SessionDescription sdp =
712 new org.webrtc.SessionDescription(
713 org.webrtc.SessionDescription.Type.OFFER, offer.toString());
714 try {
715 this.webRTCWrapper.setRemoteDescription(sdp).get();
716
717 // TODO add tracks for 'media' where contentAddition.senders matches
718
719 // TODO if senders.sending(isInitiator())
720
721 this.webRTCWrapper.addTrack(Media.VIDEO);
722
723 // TODO add additional transceivers for recv only cases
724
725 final SessionDescription answer = setLocalSessionDescription();
726 final RtpContentMap rtpContentMap = RtpContentMap.of(answer, isInitiator());
727
728 final RtpContentMap contentAcceptMap =
729 rtpContentMap.toContentModification(
730 Collections2.transform(contentAddition, ca -> ca.name));
731 Log.d(
732 Config.LOGTAG,
733 id.getAccount().getJid().asBareJid()
734 + ": sending content-accept "
735 + ContentAddition.summary(contentAcceptMap));
736 modifyLocalContentMap(rtpContentMap);
737 sendContentAccept(contentAcceptMap);
738 } catch (final Exception e) {
739 Log.d(Config.LOGTAG, "unable to accept content add", Throwables.getRootCause(e));
740 webRTCWrapper.close();
741 sendSessionTerminate(Reason.FAILED_APPLICATION);
742 }
743 }
744
745 private void sendContentAccept(final RtpContentMap contentAcceptMap) {
746 final JinglePacket jinglePacket = contentAcceptMap.toJinglePacket(JinglePacket.Action.CONTENT_ACCEPT, id.sessionId);
747 send(jinglePacket);
748 }
749
750 public synchronized void rejectContentAdd() {
751 final RtpContentMap incomingContentAdd = this.incomingContentAdd;
752 if (incomingContentAdd == null) {
753 throw new IllegalStateException("No incoming content add");
754 }
755 this.incomingContentAdd = null;
756 updateEndUserState();
757 rejectContentAdd(incomingContentAdd);
758 }
759
760 private void rejectContentAdd(final RtpContentMap contentMap) {
761 final JinglePacket jinglePacket =
762 contentMap
763 .toStub()
764 .toJinglePacket(JinglePacket.Action.CONTENT_REJECT, id.sessionId);
765 Log.d(
766 Config.LOGTAG,
767 id.getAccount().getJid().asBareJid()
768 + ": rejecting content "
769 + ContentAddition.summary(contentMap));
770 send(jinglePacket);
771 }
772
773 private boolean checkForIceRestart(
774 final JinglePacket jinglePacket, final RtpContentMap rtpContentMap) {
775 final RtpContentMap existing = getRemoteContentMap();
776 final Set<IceUdpTransportInfo.Credentials> existingCredentials;
777 final IceUdpTransportInfo.Credentials newCredentials;
778 try {
779 existingCredentials = existing.getCredentials();
780 newCredentials = rtpContentMap.getDistinctCredentials();
781 } catch (final IllegalStateException e) {
782 Log.d(Config.LOGTAG, "unable to gather credentials for comparison", e);
783 return false;
784 }
785 if (existingCredentials.contains(newCredentials)) {
786 return false;
787 }
788 // TODO an alternative approach is to check if we already got an iq result to our
789 // ICE-restart
790 // and if that's the case we are seeing an answer.
791 // This might be more spec compliant but also more error prone potentially
792 final boolean isOffer = rtpContentMap.emptyCandidates();
793 final RtpContentMap restartContentMap;
794 try {
795 if (isOffer) {
796 Log.d(Config.LOGTAG, "received offer to restart ICE " + newCredentials);
797 restartContentMap =
798 existing.modifiedCredentials(
799 newCredentials, IceUdpTransportInfo.Setup.ACTPASS);
800 } else {
801 final IceUdpTransportInfo.Setup setup = getPeerDtlsSetup();
802 Log.d(
803 Config.LOGTAG,
804 "received confirmation of ICE restart"
805 + newCredentials
806 + " peer_setup="
807 + setup);
808 // DTLS setup attribute needs to be rewritten to reflect current peer state
809 // https://groups.google.com/g/discuss-webrtc/c/DfpIMwvUfeM
810 restartContentMap = existing.modifiedCredentials(newCredentials, setup);
811 }
812 if (applyIceRestart(jinglePacket, restartContentMap, isOffer)) {
813 return isOffer;
814 } else {
815 Log.d(Config.LOGTAG, "ignoring ICE restart. sending tie-break");
816 respondWithTieBreak(jinglePacket);
817 return true;
818 }
819 } catch (final Exception exception) {
820 respondOk(jinglePacket);
821 final Throwable rootCause = Throwables.getRootCause(exception);
822 if (rootCause instanceof WebRTCWrapper.PeerConnectionNotInitialized) {
823 // If this happens a termination is already in progress
824 Log.d(Config.LOGTAG, "ignoring PeerConnectionNotInitialized on ICE restart");
825 return true;
826 }
827 Log.d(Config.LOGTAG, "failure to apply ICE restart", rootCause);
828 webRTCWrapper.close();
829 sendSessionTerminate(Reason.ofThrowable(rootCause), rootCause.getMessage());
830 return true;
831 }
832 }
833
834 private IceUdpTransportInfo.Setup getPeerDtlsSetup() {
835 final IceUdpTransportInfo.Setup peerSetup = this.peerDtlsSetup;
836 if (peerSetup == null || peerSetup == IceUdpTransportInfo.Setup.ACTPASS) {
837 throw new IllegalStateException("Invalid peer setup");
838 }
839 return peerSetup;
840 }
841
842 private void storePeerDtlsSetup(final IceUdpTransportInfo.Setup setup) {
843 if (setup == null || setup == IceUdpTransportInfo.Setup.ACTPASS) {
844 throw new IllegalArgumentException("Trying to store invalid peer dtls setup");
845 }
846 this.peerDtlsSetup = setup;
847 }
848
849 private boolean applyIceRestart(
850 final JinglePacket jinglePacket,
851 final RtpContentMap restartContentMap,
852 final boolean isOffer)
853 throws ExecutionException, InterruptedException {
854 final SessionDescription sessionDescription = SessionDescription.of(restartContentMap, !isInitiator());
855 final org.webrtc.SessionDescription.Type type =
856 isOffer
857 ? org.webrtc.SessionDescription.Type.OFFER
858 : org.webrtc.SessionDescription.Type.ANSWER;
859 org.webrtc.SessionDescription sdp =
860 new org.webrtc.SessionDescription(type, sessionDescription.toString());
861 if (isOffer && webRTCWrapper.getSignalingState() != PeerConnection.SignalingState.STABLE) {
862 if (isInitiator()) {
863 // We ignore the offer and respond with tie-break. This will clause the responder
864 // not to apply the content map
865 return false;
866 }
867 }
868 webRTCWrapper.setRemoteDescription(sdp).get();
869 setRemoteContentMap(restartContentMap);
870 if (isOffer) {
871 webRTCWrapper.setIsReadyToReceiveIceCandidates(false);
872 final SessionDescription localSessionDescription = setLocalSessionDescription();
873 setLocalContentMap(RtpContentMap.of(localSessionDescription, isInitiator()));
874 // We need to respond OK before sending any candidates
875 respondOk(jinglePacket);
876 webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
877 } else {
878 storePeerDtlsSetup(restartContentMap.getDtlsSetup());
879 }
880 return true;
881 }
882
883 private void processCandidates(
884 final Set<Map.Entry<String, RtpContentMap.DescriptionTransport>> contents) {
885 for (final Map.Entry<String, RtpContentMap.DescriptionTransport> content : contents) {
886 processCandidate(content);
887 }
888 }
889
890 private void processCandidate(
891 final Map.Entry<String, RtpContentMap.DescriptionTransport> content) {
892 final RtpContentMap rtpContentMap = getRemoteContentMap();
893 final List<String> indices = toIdentificationTags(rtpContentMap);
894 final String sdpMid = content.getKey(); // aka content name
895 final IceUdpTransportInfo transport = content.getValue().transport;
896 final IceUdpTransportInfo.Credentials credentials = transport.getCredentials();
897
898 // TODO check that credentials remained the same
899
900 for (final IceUdpTransportInfo.Candidate candidate : transport.getCandidates()) {
901 final String sdp;
902 try {
903 sdp = candidate.toSdpAttribute(credentials.ufrag);
904 } catch (final IllegalArgumentException e) {
905 Log.d(
906 Config.LOGTAG,
907 id.account.getJid().asBareJid()
908 + ": ignoring invalid ICE candidate "
909 + e.getMessage());
910 continue;
911 }
912 final int mLineIndex = indices.indexOf(sdpMid);
913 if (mLineIndex < 0) {
914 Log.w(
915 Config.LOGTAG,
916 "mLineIndex not found for " + sdpMid + ". available indices " + indices);
917 }
918 final IceCandidate iceCandidate = new IceCandidate(sdpMid, mLineIndex, sdp);
919 Log.d(Config.LOGTAG, "received candidate: " + iceCandidate);
920 this.webRTCWrapper.addIceCandidate(iceCandidate);
921 }
922 }
923
924 private RtpContentMap getRemoteContentMap() {
925 return isInitiator() ? this.responderRtpContentMap : this.initiatorRtpContentMap;
926 }
927
928 private RtpContentMap getLocalContentMap() {
929 return isInitiator() ? this.initiatorRtpContentMap : this.responderRtpContentMap;
930 }
931
932 private List<String> toIdentificationTags(final RtpContentMap rtpContentMap) {
933 final Group originalGroup = rtpContentMap.group;
934 final List<String> identificationTags =
935 originalGroup == null
936 ? rtpContentMap.getNames()
937 : originalGroup.getIdentificationTags();
938 if (identificationTags.size() == 0) {
939 Log.w(
940 Config.LOGTAG,
941 id.account.getJid().asBareJid()
942 + ": no identification tags found in initial offer. we won't be able to calculate mLineIndices");
943 }
944 return identificationTags;
945 }
946
947 private ListenableFuture<RtpContentMap> receiveRtpContentMap(
948 final JinglePacket jinglePacket, final boolean expectVerification) {
949 final RtpContentMap receivedContentMap;
950 try {
951 receivedContentMap = RtpContentMap.of(jinglePacket);
952 } catch (final Exception e) {
953 return Futures.immediateFailedFuture(e);
954 }
955 if (receivedContentMap instanceof OmemoVerifiedRtpContentMap) {
956 final ListenableFuture<AxolotlService.OmemoVerifiedPayload<RtpContentMap>> future =
957 id.account
958 .getAxolotlService()
959 .decrypt((OmemoVerifiedRtpContentMap) receivedContentMap, id.with);
960 return Futures.transform(
961 future,
962 omemoVerifiedPayload -> {
963 // TODO test if an exception here triggers a correct abort
964 omemoVerification.setOrEnsureEqual(omemoVerifiedPayload);
965 Log.d(
966 Config.LOGTAG,
967 id.account.getJid().asBareJid()
968 + ": received verifiable DTLS fingerprint via "
969 + omemoVerification);
970 return omemoVerifiedPayload.getPayload();
971 },
972 MoreExecutors.directExecutor());
973 } else if (Config.REQUIRE_RTP_VERIFICATION || expectVerification) {
974 return Futures.immediateFailedFuture(
975 new SecurityException("DTLS fingerprint was unexpectedly not verifiable"));
976 } else {
977 return Futures.immediateFuture(receivedContentMap);
978 }
979 }
980
981 private void receiveSessionInitiate(final JinglePacket jinglePacket) {
982 if (isInitiator()) {
983 Log.d(
984 Config.LOGTAG,
985 String.format(
986 "%s: received session-initiate even though we were initiating",
987 id.account.getJid().asBareJid()));
988 if (isTerminated()) {
989 Log.d(
990 Config.LOGTAG,
991 String.format(
992 "%s: got a reason to terminate with out-of-order. but already in state %s",
993 id.account.getJid().asBareJid(), getState()));
994 respondWithOutOfOrder(jinglePacket);
995 } else {
996 terminateWithOutOfOrder(jinglePacket);
997 }
998 return;
999 }
1000 final ListenableFuture<RtpContentMap> future = receiveRtpContentMap(jinglePacket, false);
1001 Futures.addCallback(
1002 future,
1003 new FutureCallback<RtpContentMap>() {
1004 @Override
1005 public void onSuccess(@Nullable RtpContentMap rtpContentMap) {
1006 receiveSessionInitiate(jinglePacket, rtpContentMap);
1007 }
1008
1009 @Override
1010 public void onFailure(@NonNull final Throwable throwable) {
1011 respondOk(jinglePacket);
1012 sendSessionTerminate(Reason.ofThrowable(throwable), throwable.getMessage());
1013 }
1014 },
1015 MoreExecutors.directExecutor());
1016 }
1017
1018 private void receiveSessionInitiate(
1019 final JinglePacket jinglePacket, final RtpContentMap contentMap) {
1020 try {
1021 contentMap.requireContentDescriptions();
1022 contentMap.requireDTLSFingerprint(true);
1023 } catch (final RuntimeException e) {
1024 Log.d(
1025 Config.LOGTAG,
1026 id.account.getJid().asBareJid() + ": improperly formatted contents",
1027 Throwables.getRootCause(e));
1028 respondOk(jinglePacket);
1029 sendSessionTerminate(Reason.of(e), e.getMessage());
1030 return;
1031 }
1032 Log.d(
1033 Config.LOGTAG,
1034 "processing session-init with " + contentMap.contents.size() + " contents");
1035 final State target;
1036 if (this.state == State.PROCEED) {
1037 Preconditions.checkState(
1038 proposedMedia != null && proposedMedia.size() > 0,
1039 "proposed media must be set when processing pre-approved session-initiate");
1040 if (!this.proposedMedia.equals(contentMap.getMedia())) {
1041 sendSessionTerminate(
1042 Reason.SECURITY_ERROR,
1043 String.format(
1044 "Your session proposal (Jingle Message Initiation) included media %s but your session-initiate was %s",
1045 this.proposedMedia, contentMap.getMedia()));
1046 return;
1047 }
1048 target = State.SESSION_INITIALIZED_PRE_APPROVED;
1049 } else {
1050 target = State.SESSION_INITIALIZED;
1051 }
1052 if (transition(target, () -> this.initiatorRtpContentMap = contentMap)) {
1053 respondOk(jinglePacket);
1054 pendingIceCandidates.addAll(contentMap.contents.entrySet());
1055 if (target == State.SESSION_INITIALIZED_PRE_APPROVED) {
1056 Log.d(
1057 Config.LOGTAG,
1058 id.account.getJid().asBareJid()
1059 + ": automatically accepting session-initiate");
1060 sendSessionAccept();
1061 } else {
1062 Log.d(
1063 Config.LOGTAG,
1064 id.account.getJid().asBareJid()
1065 + ": received not pre-approved session-initiate. start ringing");
1066 startRinging();
1067 }
1068 } else {
1069 Log.d(
1070 Config.LOGTAG,
1071 String.format(
1072 "%s: received session-initiate while in state %s",
1073 id.account.getJid().asBareJid(), state));
1074 terminateWithOutOfOrder(jinglePacket);
1075 }
1076 }
1077
1078 private void receiveSessionAccept(final JinglePacket jinglePacket) {
1079 if (!isInitiator()) {
1080 Log.d(
1081 Config.LOGTAG,
1082 String.format(
1083 "%s: received session-accept even though we were responding",
1084 id.account.getJid().asBareJid()));
1085 terminateWithOutOfOrder(jinglePacket);
1086 return;
1087 }
1088 final ListenableFuture<RtpContentMap> future =
1089 receiveRtpContentMap(jinglePacket, this.omemoVerification.hasFingerprint());
1090 Futures.addCallback(
1091 future,
1092 new FutureCallback<RtpContentMap>() {
1093 @Override
1094 public void onSuccess(@Nullable RtpContentMap rtpContentMap) {
1095 receiveSessionAccept(jinglePacket, rtpContentMap);
1096 }
1097
1098 @Override
1099 public void onFailure(@NonNull final Throwable throwable) {
1100 respondOk(jinglePacket);
1101 Log.d(
1102 Config.LOGTAG,
1103 id.account.getJid().asBareJid()
1104 + ": improperly formatted contents in session-accept",
1105 throwable);
1106 webRTCWrapper.close();
1107 sendSessionTerminate(Reason.ofThrowable(throwable), throwable.getMessage());
1108 }
1109 },
1110 MoreExecutors.directExecutor());
1111 }
1112
1113 private void receiveSessionAccept(
1114 final JinglePacket jinglePacket, final RtpContentMap contentMap) {
1115 try {
1116 contentMap.requireContentDescriptions();
1117 contentMap.requireDTLSFingerprint();
1118 } catch (final RuntimeException e) {
1119 respondOk(jinglePacket);
1120 Log.d(
1121 Config.LOGTAG,
1122 id.account.getJid().asBareJid()
1123 + ": improperly formatted contents in session-accept",
1124 e);
1125 webRTCWrapper.close();
1126 sendSessionTerminate(Reason.of(e), e.getMessage());
1127 return;
1128 }
1129 final Set<Media> initiatorMedia = this.initiatorRtpContentMap.getMedia();
1130 if (!initiatorMedia.equals(contentMap.getMedia())) {
1131 sendSessionTerminate(
1132 Reason.SECURITY_ERROR,
1133 String.format(
1134 "Your session-included included media %s but our session-initiate was %s",
1135 this.proposedMedia, contentMap.getMedia()));
1136 return;
1137 }
1138 Log.d(
1139 Config.LOGTAG,
1140 "processing session-accept with " + contentMap.contents.size() + " contents");
1141 if (transition(State.SESSION_ACCEPTED)) {
1142 respondOk(jinglePacket);
1143 receiveSessionAccept(contentMap);
1144 } else {
1145 Log.d(
1146 Config.LOGTAG,
1147 String.format(
1148 "%s: received session-accept while in state %s",
1149 id.account.getJid().asBareJid(), state));
1150 respondOk(jinglePacket);
1151 }
1152 }
1153
1154 private void receiveSessionAccept(final RtpContentMap contentMap) {
1155 this.responderRtpContentMap = contentMap;
1156 this.storePeerDtlsSetup(contentMap.getDtlsSetup());
1157 final SessionDescription sessionDescription;
1158 try {
1159 sessionDescription = SessionDescription.of(contentMap, false);
1160 } catch (final IllegalArgumentException | NullPointerException e) {
1161 Log.d(
1162 Config.LOGTAG,
1163 id.account.getJid().asBareJid()
1164 + ": unable convert offer from session-accept to SDP",
1165 e);
1166 webRTCWrapper.close();
1167 sendSessionTerminate(Reason.FAILED_APPLICATION, e.getMessage());
1168 return;
1169 }
1170 final org.webrtc.SessionDescription answer =
1171 new org.webrtc.SessionDescription(
1172 org.webrtc.SessionDescription.Type.ANSWER, sessionDescription.toString());
1173 try {
1174 this.webRTCWrapper.setRemoteDescription(answer).get();
1175 } catch (final Exception e) {
1176 Log.d(
1177 Config.LOGTAG,
1178 id.account.getJid().asBareJid()
1179 + ": unable to set remote description after receiving session-accept",
1180 Throwables.getRootCause(e));
1181 webRTCWrapper.close();
1182 sendSessionTerminate(
1183 Reason.FAILED_APPLICATION, Throwables.getRootCause(e).getMessage());
1184 return;
1185 }
1186 processCandidates(contentMap.contents.entrySet());
1187 }
1188
1189 private void sendSessionAccept() {
1190 final RtpContentMap rtpContentMap = this.initiatorRtpContentMap;
1191 if (rtpContentMap == null) {
1192 throw new IllegalStateException("initiator RTP Content Map has not been set");
1193 }
1194 final SessionDescription offer;
1195 try {
1196 offer = SessionDescription.of(rtpContentMap, true);
1197 } catch (final IllegalArgumentException | NullPointerException e) {
1198 Log.d(
1199 Config.LOGTAG,
1200 id.account.getJid().asBareJid()
1201 + ": unable convert offer from session-initiate to SDP",
1202 e);
1203 webRTCWrapper.close();
1204 sendSessionTerminate(Reason.FAILED_APPLICATION, e.getMessage());
1205 return;
1206 }
1207 sendSessionAccept(rtpContentMap.getMedia(), offer);
1208 }
1209
1210 private void sendSessionAccept(final Set<Media> media, final SessionDescription offer) {
1211 discoverIceServers(iceServers -> sendSessionAccept(media, offer, iceServers));
1212 }
1213
1214 private synchronized void sendSessionAccept(
1215 final Set<Media> media,
1216 final SessionDescription offer,
1217 final List<PeerConnection.IceServer> iceServers) {
1218 if (isTerminated()) {
1219 Log.w(
1220 Config.LOGTAG,
1221 id.account.getJid().asBareJid()
1222 + ": ICE servers got discovered when session was already terminated. nothing to do.");
1223 return;
1224 }
1225 try {
1226 setupWebRTC(media, iceServers);
1227 } catch (final WebRTCWrapper.InitializationException e) {
1228 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to initialize WebRTC");
1229 webRTCWrapper.close();
1230 sendSessionTerminate(Reason.FAILED_APPLICATION, e.getMessage());
1231 return;
1232 }
1233 final org.webrtc.SessionDescription sdp =
1234 new org.webrtc.SessionDescription(
1235 org.webrtc.SessionDescription.Type.OFFER, offer.toString());
1236 try {
1237 this.webRTCWrapper.setRemoteDescription(sdp).get();
1238 addIceCandidatesFromBlackLog();
1239 org.webrtc.SessionDescription webRTCSessionDescription =
1240 this.webRTCWrapper.setLocalDescription().get();
1241 prepareSessionAccept(webRTCSessionDescription);
1242 } catch (final Exception e) {
1243 failureToAcceptSession(e);
1244 }
1245 }
1246
1247 private void failureToAcceptSession(final Throwable throwable) {
1248 if (isTerminated()) {
1249 return;
1250 }
1251 final Throwable rootCause = Throwables.getRootCause(throwable);
1252 Log.d(Config.LOGTAG, "unable to send session accept", rootCause);
1253 webRTCWrapper.close();
1254 sendSessionTerminate(Reason.ofThrowable(rootCause), rootCause.getMessage());
1255 }
1256
1257 private void addIceCandidatesFromBlackLog() {
1258 Map.Entry<String, RtpContentMap.DescriptionTransport> foo;
1259 while ((foo = this.pendingIceCandidates.poll()) != null) {
1260 processCandidate(foo);
1261 Log.d(
1262 Config.LOGTAG,
1263 id.account.getJid().asBareJid() + ": added candidate from back log");
1264 }
1265 }
1266
1267 private void prepareSessionAccept(
1268 final org.webrtc.SessionDescription webRTCSessionDescription) {
1269 final SessionDescription sessionDescription =
1270 SessionDescription.parse(webRTCSessionDescription.description);
1271 final RtpContentMap respondingRtpContentMap = RtpContentMap.of(sessionDescription, false);
1272 this.responderRtpContentMap = respondingRtpContentMap;
1273 storePeerDtlsSetup(respondingRtpContentMap.getDtlsSetup().flip());
1274 final ListenableFuture<RtpContentMap> outgoingContentMapFuture =
1275 prepareOutgoingContentMap(respondingRtpContentMap);
1276 Futures.addCallback(
1277 outgoingContentMapFuture,
1278 new FutureCallback<RtpContentMap>() {
1279 @Override
1280 public void onSuccess(final RtpContentMap outgoingContentMap) {
1281 sendSessionAccept(outgoingContentMap);
1282 webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
1283 }
1284
1285 @Override
1286 public void onFailure(@NonNull Throwable throwable) {
1287 failureToAcceptSession(throwable);
1288 }
1289 },
1290 MoreExecutors.directExecutor());
1291 }
1292
1293 private void sendSessionAccept(final RtpContentMap rtpContentMap) {
1294 if (isTerminated()) {
1295 Log.w(
1296 Config.LOGTAG,
1297 id.account.getJid().asBareJid()
1298 + ": preparing session accept was too slow. already terminated. nothing to do.");
1299 return;
1300 }
1301 transitionOrThrow(State.SESSION_ACCEPTED);
1302 final JinglePacket sessionAccept =
1303 rtpContentMap.toJinglePacket(JinglePacket.Action.SESSION_ACCEPT, id.sessionId);
1304 send(sessionAccept);
1305 }
1306
1307 private ListenableFuture<RtpContentMap> prepareOutgoingContentMap(
1308 final RtpContentMap rtpContentMap) {
1309 if (this.omemoVerification.hasDeviceId()) {
1310 ListenableFuture<AxolotlService.OmemoVerifiedPayload<OmemoVerifiedRtpContentMap>>
1311 verifiedPayloadFuture =
1312 id.account
1313 .getAxolotlService()
1314 .encrypt(
1315 rtpContentMap,
1316 id.with,
1317 omemoVerification.getDeviceId());
1318 return Futures.transform(
1319 verifiedPayloadFuture,
1320 verifiedPayload -> {
1321 omemoVerification.setOrEnsureEqual(verifiedPayload);
1322 return verifiedPayload.getPayload();
1323 },
1324 MoreExecutors.directExecutor());
1325 } else {
1326 return Futures.immediateFuture(rtpContentMap);
1327 }
1328 }
1329
1330 synchronized void deliveryMessage(
1331 final Jid from,
1332 final Element message,
1333 final String serverMessageId,
1334 final long timestamp) {
1335 Log.d(
1336 Config.LOGTAG,
1337 id.account.getJid().asBareJid()
1338 + ": delivered message to JingleRtpConnection "
1339 + message);
1340 switch (message.getName()) {
1341 case "propose":
1342 receivePropose(from, Propose.upgrade(message), serverMessageId, timestamp);
1343 break;
1344 case "proceed":
1345 receiveProceed(from, Proceed.upgrade(message), serverMessageId, timestamp);
1346 break;
1347 case "retract":
1348 receiveRetract(from, serverMessageId, timestamp);
1349 break;
1350 case "reject":
1351 receiveReject(from, serverMessageId, timestamp);
1352 break;
1353 case "accept":
1354 receiveAccept(from, serverMessageId, timestamp);
1355 break;
1356 default:
1357 break;
1358 }
1359 }
1360
1361 void deliverFailedProceed(final String message) {
1362 Log.d(
1363 Config.LOGTAG,
1364 id.account.getJid().asBareJid() + ": receive message error for proceed message ("+Strings.nullToEmpty(message)+")");
1365 if (transition(State.TERMINATED_CONNECTIVITY_ERROR)) {
1366 webRTCWrapper.close();
1367 Log.d(
1368 Config.LOGTAG,
1369 id.account.getJid().asBareJid() + ": transitioned into connectivity error");
1370 this.finish();
1371 }
1372 }
1373
1374 private void receiveAccept(final Jid from, final String serverMsgId, final long timestamp) {
1375 final boolean originatedFromMyself =
1376 from.asBareJid().equals(id.account.getJid().asBareJid());
1377 if (originatedFromMyself) {
1378 if (transition(State.ACCEPTED)) {
1379 acceptedOnOtherDevice(serverMsgId, timestamp);
1380 } else {
1381 Log.d(
1382 Config.LOGTAG,
1383 id.account.getJid().asBareJid()
1384 + ": unable to transition to accept because already in state="
1385 + this.state);
1386 }
1387 } else {
1388 Log.d(
1389 Config.LOGTAG,
1390 id.account.getJid().asBareJid() + ": ignoring 'accept' from " + from);
1391 }
1392 }
1393
1394 private void acceptedOnOtherDevice(final String serverMsgId, final long timestamp) {
1395 if (serverMsgId != null) {
1396 this.message.setServerMsgId(serverMsgId);
1397 }
1398 this.message.setTime(timestamp);
1399 this.message.setCarbon(true); // indicate that call was accepted on other device
1400 this.writeLogMessageSuccess(0);
1401 this.xmppConnectionService
1402 .getNotificationService()
1403 .cancelIncomingCallNotification();
1404 this.finish();
1405 }
1406
1407 private void receiveReject(final Jid from, final String serverMsgId, final long timestamp) {
1408 final boolean originatedFromMyself =
1409 from.asBareJid().equals(id.account.getJid().asBareJid());
1410 // reject from another one of my clients
1411 if (originatedFromMyself) {
1412 receiveRejectFromMyself(serverMsgId, timestamp);
1413 } else if (isInitiator()) {
1414 if (from.equals(id.with)) {
1415 receiveRejectFromResponder();
1416 } else {
1417 Log.d(
1418 Config.LOGTAG,
1419 id.account.getJid()
1420 + ": ignoring reject from "
1421 + from
1422 + " for session with "
1423 + id.with);
1424 }
1425 } else {
1426 Log.d(
1427 Config.LOGTAG,
1428 id.account.getJid()
1429 + ": ignoring reject from "
1430 + from
1431 + " for session with "
1432 + id.with);
1433 }
1434 }
1435
1436 private void receiveRejectFromMyself(String serverMsgId, long timestamp) {
1437 if (transition(State.REJECTED)) {
1438 this.xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
1439 this.finish();
1440 if (serverMsgId != null) {
1441 this.message.setServerMsgId(serverMsgId);
1442 }
1443 this.message.setTime(timestamp);
1444 this.message.setCarbon(true); // indicate that call was rejected on other device
1445 writeLogMessageMissed();
1446 } else {
1447 Log.d(
1448 Config.LOGTAG,
1449 "not able to transition into REJECTED because already in " + this.state);
1450 }
1451 }
1452
1453 private void receiveRejectFromResponder() {
1454 if (isInState(State.PROCEED)) {
1455 Log.d(
1456 Config.LOGTAG,
1457 id.account.getJid()
1458 + ": received reject while still in proceed. callee reconsidered");
1459 closeTransitionLogFinish(State.REJECTED_RACED);
1460 return;
1461 }
1462 if (isInState(State.SESSION_INITIALIZED_PRE_APPROVED)) {
1463 Log.d(
1464 Config.LOGTAG,
1465 id.account.getJid()
1466 + ": received reject while in SESSION_INITIATED_PRE_APPROVED. callee reconsidered before receiving session-init");
1467 closeTransitionLogFinish(State.TERMINATED_DECLINED_OR_BUSY);
1468 return;
1469 }
1470 Log.d(
1471 Config.LOGTAG,
1472 id.account.getJid()
1473 + ": ignoring reject from responder because already in state "
1474 + this.state);
1475 }
1476
1477 private void receivePropose(
1478 final Jid from, final Propose propose, final String serverMsgId, final long timestamp) {
1479 final boolean originatedFromMyself =
1480 from.asBareJid().equals(id.account.getJid().asBareJid());
1481 if (originatedFromMyself) {
1482 Log.d(
1483 Config.LOGTAG,
1484 id.account.getJid().asBareJid() + ": saw proposal from myself. ignoring");
1485 } else if (transition(
1486 State.PROPOSED,
1487 () -> {
1488 final Collection<RtpDescription> descriptions =
1489 Collections2.transform(
1490 Collections2.filter(
1491 propose.getDescriptions(),
1492 d -> d instanceof RtpDescription),
1493 input -> (RtpDescription) input);
1494 final Collection<Media> media =
1495 Collections2.transform(descriptions, RtpDescription::getMedia);
1496 Preconditions.checkState(
1497 !media.contains(Media.UNKNOWN),
1498 "RTP descriptions contain unknown media");
1499 Log.d(
1500 Config.LOGTAG,
1501 id.account.getJid().asBareJid()
1502 + ": received session proposal from "
1503 + from
1504 + " for "
1505 + media);
1506 this.proposedMedia = Sets.newHashSet(media);
1507 })) {
1508 if (serverMsgId != null) {
1509 this.message.setServerMsgId(serverMsgId);
1510 }
1511 this.message.setTime(timestamp);
1512 startRinging();
1513 } else {
1514 Log.d(
1515 Config.LOGTAG,
1516 id.account.getJid()
1517 + ": ignoring session proposal because already in "
1518 + state);
1519 }
1520 }
1521
1522 private void startRinging() {
1523 Log.d(
1524 Config.LOGTAG,
1525 id.account.getJid().asBareJid()
1526 + ": received call from "
1527 + id.with
1528 + ". start ringing");
1529 ringingTimeoutFuture =
1530 jingleConnectionManager.schedule(
1531 this::ringingTimeout, BUSY_TIME_OUT, TimeUnit.SECONDS);
1532 xmppConnectionService.getNotificationService().startRinging(id, getMedia());
1533 }
1534
1535 private synchronized void ringingTimeout() {
1536 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": timeout reached for ringing");
1537 switch (this.state) {
1538 case PROPOSED:
1539 message.markUnread();
1540 rejectCallFromProposed();
1541 break;
1542 case SESSION_INITIALIZED:
1543 message.markUnread();
1544 rejectCallFromSessionInitiate();
1545 break;
1546 }
1547 xmppConnectionService.getNotificationService().pushMissedCallNow(message);
1548 }
1549
1550 private void cancelRingingTimeout() {
1551 final ScheduledFuture<?> future = this.ringingTimeoutFuture;
1552 if (future != null && !future.isCancelled()) {
1553 future.cancel(false);
1554 }
1555 }
1556
1557 private void receiveProceed(
1558 final Jid from, final Proceed proceed, final String serverMsgId, final long timestamp) {
1559 final Set<Media> media =
1560 Preconditions.checkNotNull(
1561 this.proposedMedia, "Proposed media has to be set before handling proceed");
1562 Preconditions.checkState(media.size() > 0, "Proposed media should not be empty");
1563 if (from.equals(id.with)) {
1564 if (isInitiator()) {
1565 if (transition(State.PROCEED)) {
1566 if (serverMsgId != null) {
1567 this.message.setServerMsgId(serverMsgId);
1568 }
1569 this.message.setTime(timestamp);
1570 final Integer remoteDeviceId = proceed.getDeviceId();
1571 if (isOmemoEnabled()) {
1572 this.omemoVerification.setDeviceId(remoteDeviceId);
1573 } else {
1574 if (remoteDeviceId != null) {
1575 Log.d(
1576 Config.LOGTAG,
1577 id.account.getJid().asBareJid()
1578 + ": remote party signaled support for OMEMO verification but we have OMEMO disabled");
1579 }
1580 this.omemoVerification.setDeviceId(null);
1581 }
1582 this.sendSessionInitiate(media, State.SESSION_INITIALIZED_PRE_APPROVED);
1583 } else {
1584 Log.d(
1585 Config.LOGTAG,
1586 String.format(
1587 "%s: ignoring proceed because already in %s",
1588 id.account.getJid().asBareJid(), this.state));
1589 }
1590 } else {
1591 Log.d(
1592 Config.LOGTAG,
1593 String.format(
1594 "%s: ignoring proceed because we were not initializing",
1595 id.account.getJid().asBareJid()));
1596 }
1597 } else if (from.asBareJid().equals(id.account.getJid().asBareJid())) {
1598 if (transition(State.ACCEPTED)) {
1599 Log.d(
1600 Config.LOGTAG,
1601 id.account.getJid().asBareJid()
1602 + ": moved session with "
1603 + id.with
1604 + " into state accepted after received carbon copied proceed");
1605 acceptedOnOtherDevice(serverMsgId, timestamp);
1606 }
1607 } else {
1608 Log.d(
1609 Config.LOGTAG,
1610 String.format(
1611 "%s: ignoring proceed from %s. was expected from %s",
1612 id.account.getJid().asBareJid(), from, id.with));
1613 }
1614 }
1615
1616 private void receiveRetract(final Jid from, final String serverMsgId, final long timestamp) {
1617 if (from.equals(id.with)) {
1618 final State target =
1619 this.state == State.PROCEED ? State.RETRACTED_RACED : State.RETRACTED;
1620 if (transition(target)) {
1621 xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
1622 xmppConnectionService.getNotificationService().pushMissedCallNow(message);
1623 Log.d(
1624 Config.LOGTAG,
1625 id.account.getJid().asBareJid()
1626 + ": session with "
1627 + id.with
1628 + " has been retracted (serverMsgId="
1629 + serverMsgId
1630 + ")");
1631 if (serverMsgId != null) {
1632 this.message.setServerMsgId(serverMsgId);
1633 }
1634 this.message.setTime(timestamp);
1635 if (target == State.RETRACTED) {
1636 this.message.markUnread();
1637 }
1638 writeLogMessageMissed();
1639 finish();
1640 } else {
1641 Log.d(Config.LOGTAG, "ignoring retract because already in " + this.state);
1642 }
1643 } else {
1644 // TODO parse retract from self
1645 Log.d(
1646 Config.LOGTAG,
1647 id.account.getJid().asBareJid()
1648 + ": received retract from "
1649 + from
1650 + ". expected retract from"
1651 + id.with
1652 + ". ignoring");
1653 }
1654 }
1655
1656 public void sendSessionInitiate() {
1657 sendSessionInitiate(this.proposedMedia, State.SESSION_INITIALIZED);
1658 }
1659
1660 private void sendSessionInitiate(final Set<Media> media, final State targetState) {
1661 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": prepare session-initiate");
1662 discoverIceServers(iceServers -> sendSessionInitiate(media, targetState, iceServers));
1663 }
1664
1665 private synchronized void sendSessionInitiate(
1666 final Set<Media> media,
1667 final State targetState,
1668 final List<PeerConnection.IceServer> iceServers) {
1669 if (isTerminated()) {
1670 Log.w(
1671 Config.LOGTAG,
1672 id.account.getJid().asBareJid()
1673 + ": ICE servers got discovered when session was already terminated. nothing to do.");
1674 return;
1675 }
1676 try {
1677 setupWebRTC(media, iceServers);
1678 } catch (final WebRTCWrapper.InitializationException e) {
1679 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to initialize WebRTC");
1680 webRTCWrapper.close();
1681 sendRetract(Reason.ofThrowable(e));
1682 return;
1683 }
1684 try {
1685 org.webrtc.SessionDescription webRTCSessionDescription =
1686 this.webRTCWrapper.setLocalDescription().get();
1687 prepareSessionInitiate(webRTCSessionDescription, targetState);
1688 } catch (final Exception e) {
1689 // TODO sending the error text is worthwhile as well. Especially for FailureToSet
1690 // exceptions
1691 failureToInitiateSession(e, targetState);
1692 }
1693 }
1694
1695 private void failureToInitiateSession(final Throwable throwable, final State targetState) {
1696 if (isTerminated()) {
1697 return;
1698 }
1699 Log.d(
1700 Config.LOGTAG,
1701 id.account.getJid().asBareJid() + ": unable to sendSessionInitiate",
1702 Throwables.getRootCause(throwable));
1703 webRTCWrapper.close();
1704 final Reason reason = Reason.ofThrowable(throwable);
1705 if (isInState(targetState)) {
1706 sendSessionTerminate(reason, throwable.getMessage());
1707 } else {
1708 sendRetract(reason);
1709 }
1710 }
1711
1712 private void sendRetract(final Reason reason) {
1713 // TODO embed reason into retract
1714 sendJingleMessage("retract", id.with.asBareJid());
1715 transitionOrThrow(reasonToState(reason));
1716 this.finish();
1717 }
1718
1719 private void prepareSessionInitiate(
1720 final org.webrtc.SessionDescription webRTCSessionDescription, final State targetState) {
1721 final SessionDescription sessionDescription =
1722 SessionDescription.parse(webRTCSessionDescription.description);
1723 final RtpContentMap rtpContentMap = RtpContentMap.of(sessionDescription, true);
1724 this.initiatorRtpContentMap = rtpContentMap;
1725 final ListenableFuture<RtpContentMap> outgoingContentMapFuture =
1726 encryptSessionInitiate(rtpContentMap);
1727 Futures.addCallback(
1728 outgoingContentMapFuture,
1729 new FutureCallback<RtpContentMap>() {
1730 @Override
1731 public void onSuccess(final RtpContentMap outgoingContentMap) {
1732 sendSessionInitiate(outgoingContentMap, targetState);
1733 webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
1734 }
1735
1736 @Override
1737 public void onFailure(@NonNull final Throwable throwable) {
1738 failureToInitiateSession(throwable, targetState);
1739 }
1740 },
1741 MoreExecutors.directExecutor());
1742 }
1743
1744 private void sendSessionInitiate(final RtpContentMap rtpContentMap, final State targetState) {
1745 if (isTerminated()) {
1746 Log.w(
1747 Config.LOGTAG,
1748 id.account.getJid().asBareJid()
1749 + ": preparing session was too slow. already terminated. nothing to do.");
1750 return;
1751 }
1752 this.transitionOrThrow(targetState);
1753 final JinglePacket sessionInitiate =
1754 rtpContentMap.toJinglePacket(JinglePacket.Action.SESSION_INITIATE, id.sessionId);
1755 send(sessionInitiate);
1756 }
1757
1758 private ListenableFuture<RtpContentMap> encryptSessionInitiate(
1759 final RtpContentMap rtpContentMap) {
1760 if (this.omemoVerification.hasDeviceId()) {
1761 final ListenableFuture<AxolotlService.OmemoVerifiedPayload<OmemoVerifiedRtpContentMap>>
1762 verifiedPayloadFuture =
1763 id.account
1764 .getAxolotlService()
1765 .encrypt(
1766 rtpContentMap,
1767 id.with,
1768 omemoVerification.getDeviceId());
1769 final ListenableFuture<RtpContentMap> future =
1770 Futures.transform(
1771 verifiedPayloadFuture,
1772 verifiedPayload -> {
1773 omemoVerification.setSessionFingerprint(
1774 verifiedPayload.getFingerprint());
1775 return verifiedPayload.getPayload();
1776 },
1777 MoreExecutors.directExecutor());
1778 if (Config.REQUIRE_RTP_VERIFICATION) {
1779 return future;
1780 }
1781 return Futures.catching(
1782 future,
1783 CryptoFailedException.class,
1784 e -> {
1785 Log.w(
1786 Config.LOGTAG,
1787 id.account.getJid().asBareJid()
1788 + ": unable to use OMEMO DTLS verification on outgoing session initiate. falling back",
1789 e);
1790 return rtpContentMap;
1791 },
1792 MoreExecutors.directExecutor());
1793 } else {
1794 return Futures.immediateFuture(rtpContentMap);
1795 }
1796 }
1797
1798 private void sendSessionTerminate(final Reason reason) {
1799 sendSessionTerminate(reason, null);
1800 }
1801
1802 private void sendSessionTerminate(final Reason reason, final String text) {
1803 final State previous = this.state;
1804 final State target = reasonToState(reason);
1805 transitionOrThrow(target);
1806 if (previous != State.NULL) {
1807 writeLogMessage(target);
1808 }
1809 final JinglePacket jinglePacket =
1810 new JinglePacket(JinglePacket.Action.SESSION_TERMINATE, id.sessionId);
1811 jinglePacket.setReason(reason, text);
1812 Log.d(Config.LOGTAG, jinglePacket.toString());
1813 send(jinglePacket);
1814 finish();
1815 }
1816
1817 private void sendTransportInfo(
1818 final String contentName, IceUdpTransportInfo.Candidate candidate) {
1819 final RtpContentMap transportInfo;
1820 try {
1821 final RtpContentMap rtpContentMap =
1822 isInitiator() ? this.initiatorRtpContentMap : this.responderRtpContentMap;
1823 transportInfo = rtpContentMap.transportInfo(contentName, candidate);
1824 } catch (final Exception e) {
1825 Log.d(
1826 Config.LOGTAG,
1827 id.account.getJid().asBareJid()
1828 + ": unable to prepare transport-info from candidate for content="
1829 + contentName);
1830 return;
1831 }
1832 final JinglePacket jinglePacket =
1833 transportInfo.toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
1834 send(jinglePacket);
1835 }
1836
1837 private void send(final JinglePacket jinglePacket) {
1838 jinglePacket.setTo(id.with);
1839 xmppConnectionService.sendIqPacket(id.account, jinglePacket, this::handleIqResponse);
1840 }
1841
1842 private synchronized void handleIqResponse(final Account account, final IqPacket response) {
1843 if (response.getType() == IqPacket.TYPE.ERROR) {
1844 handleIqErrorResponse(response);
1845 return;
1846 }
1847 if (response.getType() == IqPacket.TYPE.TIMEOUT) {
1848 handleIqTimeoutResponse(response);
1849 }
1850 }
1851
1852 private void handleIqErrorResponse(final IqPacket response) {
1853 Preconditions.checkArgument(response.getType() == IqPacket.TYPE.ERROR);
1854 final String errorCondition = response.getErrorCondition();
1855 Log.d(
1856 Config.LOGTAG,
1857 id.account.getJid().asBareJid()
1858 + ": received IQ-error from "
1859 + response.getFrom()
1860 + " in RTP session. "
1861 + errorCondition);
1862 if (isTerminated()) {
1863 Log.i(
1864 Config.LOGTAG,
1865 id.account.getJid().asBareJid()
1866 + ": ignoring error because session was already terminated");
1867 return;
1868 }
1869 this.webRTCWrapper.close();
1870 final State target;
1871 if (Arrays.asList(
1872 "service-unavailable",
1873 "recipient-unavailable",
1874 "remote-server-not-found",
1875 "remote-server-timeout")
1876 .contains(errorCondition)) {
1877 target = State.TERMINATED_CONNECTIVITY_ERROR;
1878 } else {
1879 target = State.TERMINATED_APPLICATION_FAILURE;
1880 }
1881 transitionOrThrow(target);
1882 this.finish();
1883 }
1884
1885 private void handleIqTimeoutResponse(final IqPacket response) {
1886 Preconditions.checkArgument(response.getType() == IqPacket.TYPE.TIMEOUT);
1887 Log.d(
1888 Config.LOGTAG,
1889 id.account.getJid().asBareJid()
1890 + ": received IQ timeout in RTP session with "
1891 + id.with
1892 + ". terminating with connectivity error");
1893 if (isTerminated()) {
1894 Log.i(
1895 Config.LOGTAG,
1896 id.account.getJid().asBareJid()
1897 + ": ignoring error because session was already terminated");
1898 return;
1899 }
1900 this.webRTCWrapper.close();
1901 transitionOrThrow(State.TERMINATED_CONNECTIVITY_ERROR);
1902 this.finish();
1903 }
1904
1905 private void terminateWithOutOfOrder(final JinglePacket jinglePacket) {
1906 Log.d(
1907 Config.LOGTAG,
1908 id.account.getJid().asBareJid() + ": terminating session with out-of-order");
1909 this.webRTCWrapper.close();
1910 transitionOrThrow(State.TERMINATED_APPLICATION_FAILURE);
1911 respondWithOutOfOrder(jinglePacket);
1912 this.finish();
1913 }
1914
1915 private void respondWithTieBreak(final JinglePacket jinglePacket) {
1916 respondWithJingleError(jinglePacket, "tie-break", "conflict", "cancel");
1917 }
1918
1919 private void respondWithOutOfOrder(final JinglePacket jinglePacket) {
1920 respondWithJingleError(jinglePacket, "out-of-order", "unexpected-request", "wait");
1921 }
1922
1923 void respondWithJingleError(
1924 final IqPacket original,
1925 String jingleCondition,
1926 String condition,
1927 String conditionType) {
1928 jingleConnectionManager.respondWithJingleError(
1929 id.account, original, jingleCondition, condition, conditionType);
1930 }
1931
1932 private void respondOk(final JinglePacket jinglePacket) {
1933 xmppConnectionService.sendIqPacket(
1934 id.account, jinglePacket.generateResponse(IqPacket.TYPE.RESULT), null);
1935 }
1936
1937 public RtpEndUserState getEndUserState() {
1938 switch (this.state) {
1939 case NULL:
1940 case PROPOSED:
1941 case SESSION_INITIALIZED:
1942 if (isInitiator()) {
1943 return RtpEndUserState.RINGING;
1944 } else {
1945 return RtpEndUserState.INCOMING_CALL;
1946 }
1947 case PROCEED:
1948 if (isInitiator()) {
1949 return RtpEndUserState.RINGING;
1950 } else {
1951 return RtpEndUserState.ACCEPTING_CALL;
1952 }
1953 case SESSION_INITIALIZED_PRE_APPROVED:
1954 if (isInitiator()) {
1955 return RtpEndUserState.RINGING;
1956 } else {
1957 return RtpEndUserState.CONNECTING;
1958 }
1959 case SESSION_ACCEPTED:
1960 final ContentAddition ca = getPendingContentAddition();
1961 if (ca != null && ca.direction == ContentAddition.Direction.INCOMING) {
1962 return RtpEndUserState.INCOMING_CONTENT_ADD;
1963 }
1964 return getPeerConnectionStateAsEndUserState();
1965 case REJECTED:
1966 case REJECTED_RACED:
1967 case TERMINATED_DECLINED_OR_BUSY:
1968 if (isInitiator()) {
1969 return RtpEndUserState.DECLINED_OR_BUSY;
1970 } else {
1971 return RtpEndUserState.ENDED;
1972 }
1973 case TERMINATED_SUCCESS:
1974 case ACCEPTED:
1975 case RETRACTED:
1976 case TERMINATED_CANCEL_OR_TIMEOUT:
1977 return RtpEndUserState.ENDED;
1978 case RETRACTED_RACED:
1979 if (isInitiator()) {
1980 return RtpEndUserState.ENDED;
1981 } else {
1982 return RtpEndUserState.RETRACTED;
1983 }
1984 case TERMINATED_CONNECTIVITY_ERROR:
1985 return zeroDuration()
1986 ? RtpEndUserState.CONNECTIVITY_ERROR
1987 : RtpEndUserState.CONNECTIVITY_LOST_ERROR;
1988 case TERMINATED_APPLICATION_FAILURE:
1989 return RtpEndUserState.APPLICATION_ERROR;
1990 case TERMINATED_SECURITY_ERROR:
1991 return RtpEndUserState.SECURITY_ERROR;
1992 }
1993 throw new IllegalStateException(
1994 String.format("%s has no equivalent EndUserState", this.state));
1995 }
1996
1997 private RtpEndUserState getPeerConnectionStateAsEndUserState() {
1998 final PeerConnection.PeerConnectionState state;
1999 try {
2000 state = webRTCWrapper.getState();
2001 } catch (final WebRTCWrapper.PeerConnectionNotInitialized e) {
2002 // We usually close the WebRTCWrapper *before* transitioning so we might still
2003 // be in SESSION_ACCEPTED even though the peerConnection has been torn down
2004 return RtpEndUserState.ENDING_CALL;
2005 }
2006 switch (state) {
2007 case CONNECTED:
2008 return RtpEndUserState.CONNECTED;
2009 case NEW:
2010 case CONNECTING:
2011 return RtpEndUserState.CONNECTING;
2012 case CLOSED:
2013 return RtpEndUserState.ENDING_CALL;
2014 default:
2015 return zeroDuration()
2016 ? RtpEndUserState.CONNECTIVITY_ERROR
2017 : RtpEndUserState.RECONNECTING;
2018 }
2019 }
2020
2021 public ContentAddition getPendingContentAddition() {
2022 final RtpContentMap in = this.incomingContentAdd;
2023 final RtpContentMap out = this.outgoingContentAdd;
2024 if (out != null) {
2025 return ContentAddition.of(ContentAddition.Direction.OUTGOING, out);
2026 } else if (in != null) {
2027 return ContentAddition.of(ContentAddition.Direction.INCOMING, in);
2028 } else {
2029 return null;
2030 }
2031 }
2032
2033 public Set<Media> getMedia() {
2034 final State current = getState();
2035 if (current == State.NULL) {
2036 if (isInitiator()) {
2037 return Preconditions.checkNotNull(
2038 this.proposedMedia, "RTP connection has not been initialized properly");
2039 }
2040 throw new IllegalStateException("RTP connection has not been initialized yet");
2041 }
2042 if (Arrays.asList(State.PROPOSED, State.PROCEED).contains(current)) {
2043 return Preconditions.checkNotNull(
2044 this.proposedMedia, "RTP connection has not been initialized properly");
2045 }
2046 final RtpContentMap localContentMap = getLocalContentMap();
2047 final RtpContentMap initiatorContentMap = initiatorRtpContentMap;
2048 if (localContentMap != null) {
2049 return localContentMap.getMedia();
2050 } else if (initiatorContentMap != null) {
2051 return initiatorContentMap.getMedia();
2052 } else if (isTerminated()) {
2053 return Collections.emptySet(); //we might fail before we ever got a chance to set media
2054 } else {
2055 return Preconditions.checkNotNull(this.proposedMedia, "RTP connection has not been initialized properly");
2056 }
2057 }
2058
2059 public boolean isVerified() {
2060 final String fingerprint = this.omemoVerification.getFingerprint();
2061 if (fingerprint == null) {
2062 return false;
2063 }
2064 final FingerprintStatus status =
2065 id.account.getAxolotlService().getFingerprintTrust(fingerprint);
2066 return status != null && status.isVerified();
2067 }
2068
2069 public boolean addMedia(final Media media) {
2070 final Set<Media> currentMedia = getMedia();
2071 if (currentMedia.contains(media)) {
2072 throw new IllegalStateException(String.format("%s has already been proposed", media));
2073 }
2074 // TODO add state protection - can only add while ACCEPTED or so
2075 Log.d(Config.LOGTAG,"adding media: "+media);
2076 return webRTCWrapper.addTrack(media);
2077 }
2078
2079 public synchronized void acceptCall() {
2080 switch (this.state) {
2081 case PROPOSED:
2082 cancelRingingTimeout();
2083 acceptCallFromProposed();
2084 break;
2085 case SESSION_INITIALIZED:
2086 cancelRingingTimeout();
2087 acceptCallFromSessionInitialized();
2088 break;
2089 case ACCEPTED:
2090 Log.w(
2091 Config.LOGTAG,
2092 id.account.getJid().asBareJid()
2093 + ": the call has already been accepted with another client. UI was just lagging behind");
2094 break;
2095 case PROCEED:
2096 case SESSION_ACCEPTED:
2097 Log.w(
2098 Config.LOGTAG,
2099 id.account.getJid().asBareJid()
2100 + ": the call has already been accepted. user probably double tapped the UI");
2101 break;
2102 default:
2103 throw new IllegalStateException("Can not accept call from " + this.state);
2104 }
2105 }
2106
2107 public void notifyPhoneCall() {
2108 Log.d(Config.LOGTAG, "a phone call has just been started. killing jingle rtp connections");
2109 if (Arrays.asList(State.PROPOSED, State.SESSION_INITIALIZED).contains(this.state)) {
2110 rejectCall();
2111 } else {
2112 endCall();
2113 }
2114 }
2115
2116 public synchronized void rejectCall() {
2117 if (isTerminated()) {
2118 Log.w(
2119 Config.LOGTAG,
2120 id.account.getJid().asBareJid()
2121 + ": received rejectCall() when session has already been terminated. nothing to do");
2122 return;
2123 }
2124 switch (this.state) {
2125 case PROPOSED:
2126 rejectCallFromProposed();
2127 break;
2128 case SESSION_INITIALIZED:
2129 rejectCallFromSessionInitiate();
2130 break;
2131 default:
2132 throw new IllegalStateException("Can not reject call from " + this.state);
2133 }
2134 }
2135
2136 public synchronized void endCall() {
2137 if (isTerminated()) {
2138 Log.w(
2139 Config.LOGTAG,
2140 id.account.getJid().asBareJid()
2141 + ": received endCall() when session has already been terminated. nothing to do");
2142 return;
2143 }
2144 if (isInState(State.PROPOSED) && !isInitiator()) {
2145 rejectCallFromProposed();
2146 return;
2147 }
2148 if (isInState(State.PROCEED)) {
2149 if (isInitiator()) {
2150 retractFromProceed();
2151 } else {
2152 rejectCallFromProceed();
2153 }
2154 return;
2155 }
2156 if (isInitiator()
2157 && isInState(State.SESSION_INITIALIZED, State.SESSION_INITIALIZED_PRE_APPROVED)) {
2158 this.webRTCWrapper.close();
2159 sendSessionTerminate(Reason.CANCEL);
2160 return;
2161 }
2162 if (isInState(State.SESSION_INITIALIZED)) {
2163 rejectCallFromSessionInitiate();
2164 return;
2165 }
2166 if (isInState(State.SESSION_INITIALIZED_PRE_APPROVED, State.SESSION_ACCEPTED)) {
2167 this.webRTCWrapper.close();
2168 sendSessionTerminate(Reason.SUCCESS);
2169 return;
2170 }
2171 if (isInState(
2172 State.TERMINATED_APPLICATION_FAILURE,
2173 State.TERMINATED_CONNECTIVITY_ERROR,
2174 State.TERMINATED_DECLINED_OR_BUSY)) {
2175 Log.d(
2176 Config.LOGTAG,
2177 "ignoring request to end call because already in state " + this.state);
2178 return;
2179 }
2180 throw new IllegalStateException(
2181 "called 'endCall' while in state " + this.state + ". isInitiator=" + isInitiator());
2182 }
2183
2184 private void retractFromProceed() {
2185 Log.d(Config.LOGTAG, "retract from proceed");
2186 this.sendJingleMessage("retract");
2187 closeTransitionLogFinish(State.RETRACTED_RACED);
2188 }
2189
2190 private void closeTransitionLogFinish(final State state) {
2191 this.webRTCWrapper.close();
2192 transitionOrThrow(state);
2193 writeLogMessage(state);
2194 finish();
2195 }
2196
2197 private void setupWebRTC(final Set<Media> media, final List<PeerConnection.IceServer> iceServers) throws WebRTCWrapper.InitializationException {
2198 this.jingleConnectionManager.ensureConnectionIsRegistered(this);
2199 this.webRTCWrapper.setup(this.xmppConnectionService, AppRTCAudioManager.SpeakerPhonePreference.of(media));
2200 this.webRTCWrapper.initializePeerConnection(media, iceServers);
2201 }
2202
2203 private void acceptCallFromProposed() {
2204 transitionOrThrow(State.PROCEED);
2205 xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
2206 this.sendJingleMessage("accept", id.account.getJid().asBareJid());
2207 this.sendJingleMessage("proceed");
2208 }
2209
2210 private void rejectCallFromProposed() {
2211 transitionOrThrow(State.REJECTED);
2212 writeLogMessageMissed();
2213 xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
2214 this.sendJingleMessage("reject");
2215 finish();
2216 }
2217
2218 private void rejectCallFromProceed() {
2219 this.sendJingleMessage("reject");
2220 closeTransitionLogFinish(State.REJECTED_RACED);
2221 }
2222
2223 private void rejectCallFromSessionInitiate() {
2224 webRTCWrapper.close();
2225 sendSessionTerminate(Reason.DECLINE);
2226 xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
2227 }
2228
2229 private void sendJingleMessage(final String action) {
2230 sendJingleMessage(action, id.with);
2231 }
2232
2233 private void sendJingleMessage(final String action, final Jid to) {
2234 final MessagePacket messagePacket = new MessagePacket();
2235 messagePacket.setType(MessagePacket.TYPE_CHAT); // we want to carbon copy those
2236 messagePacket.setTo(to);
2237 final Element intent =
2238 messagePacket
2239 .addChild(action, Namespace.JINGLE_MESSAGE)
2240 .setAttribute("id", id.sessionId);
2241 if ("proceed".equals(action)) {
2242 messagePacket.setId(JINGLE_MESSAGE_PROCEED_ID_PREFIX + id.sessionId);
2243 if (isOmemoEnabled()) {
2244 final int deviceId = id.account.getAxolotlService().getOwnDeviceId();
2245 final Element device =
2246 intent.addChild("device", Namespace.OMEMO_DTLS_SRTP_VERIFICATION);
2247 device.setAttribute("id", deviceId);
2248 }
2249 }
2250 messagePacket.addChild("store", "urn:xmpp:hints");
2251 xmppConnectionService.sendMessagePacket(id.account, messagePacket);
2252 }
2253
2254 private boolean isOmemoEnabled() {
2255 final Conversational conversational = message.getConversation();
2256 if (conversational instanceof Conversation) {
2257 return ((Conversation) conversational).getNextEncryption()
2258 == Message.ENCRYPTION_AXOLOTL;
2259 }
2260 return false;
2261 }
2262
2263 private void acceptCallFromSessionInitialized() {
2264 xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
2265 sendSessionAccept();
2266 }
2267
2268 private synchronized boolean isInState(State... state) {
2269 return Arrays.asList(state).contains(this.state);
2270 }
2271
2272 private boolean transition(final State target) {
2273 return transition(target, null);
2274 }
2275
2276 private synchronized boolean transition(final State target, final Runnable runnable) {
2277 final Collection<State> validTransitions = VALID_TRANSITIONS.get(this.state);
2278 if (validTransitions != null && validTransitions.contains(target)) {
2279 this.state = target;
2280 if (runnable != null) {
2281 runnable.run();
2282 }
2283 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": transitioned into " + target);
2284 updateEndUserState();
2285 updateOngoingCallNotification();
2286 return true;
2287 } else {
2288 return false;
2289 }
2290 }
2291
2292 void transitionOrThrow(final State target) {
2293 if (!transition(target)) {
2294 throw new IllegalStateException(
2295 String.format("Unable to transition from %s to %s", this.state, target));
2296 }
2297 }
2298
2299 @Override
2300 public void onIceCandidate(final IceCandidate iceCandidate) {
2301 final RtpContentMap rtpContentMap =
2302 isInitiator() ? this.initiatorRtpContentMap : this.responderRtpContentMap;
2303 final IceUdpTransportInfo.Credentials credentials;
2304 try {
2305 credentials = rtpContentMap.getCredentials(iceCandidate.sdpMid);
2306 } catch (final IllegalArgumentException e) {
2307 Log.d(Config.LOGTAG, "ignoring (not sending) candidate: " + iceCandidate, e);
2308 return;
2309 }
2310 final String uFrag = credentials.ufrag;
2311 final IceUdpTransportInfo.Candidate candidate =
2312 IceUdpTransportInfo.Candidate.fromSdpAttribute(iceCandidate.sdp, uFrag);
2313 if (candidate == null) {
2314 Log.d(Config.LOGTAG, "ignoring (not sending) candidate: " + iceCandidate);
2315 return;
2316 }
2317 Log.d(Config.LOGTAG, "sending candidate: " + iceCandidate);
2318 sendTransportInfo(iceCandidate.sdpMid, candidate);
2319 }
2320
2321 @Override
2322 public void onConnectionChange(final PeerConnection.PeerConnectionState newState) {
2323 Log.d(
2324 Config.LOGTAG,
2325 id.account.getJid().asBareJid() + ": PeerConnectionState changed to " + newState);
2326 this.stateHistory.add(newState);
2327 if (newState == PeerConnection.PeerConnectionState.CONNECTED) {
2328 this.sessionDuration.start();
2329 updateOngoingCallNotification();
2330 } else if (this.sessionDuration.isRunning()) {
2331 this.sessionDuration.stop();
2332 updateOngoingCallNotification();
2333 }
2334
2335 final boolean neverConnected =
2336 !this.stateHistory.contains(PeerConnection.PeerConnectionState.CONNECTED);
2337
2338 if (newState == PeerConnection.PeerConnectionState.FAILED) {
2339 if (neverConnected) {
2340 if (isTerminated()) {
2341 Log.d(
2342 Config.LOGTAG,
2343 id.account.getJid().asBareJid()
2344 + ": not sending session-terminate after connectivity error because session is already in state "
2345 + this.state);
2346 return;
2347 }
2348 webRTCWrapper.execute(this::closeWebRTCSessionAfterFailedConnection);
2349 return;
2350 } else {
2351 this.restartIce();
2352 }
2353 }
2354 updateEndUserState();
2355 }
2356
2357 private void restartIce() {
2358 this.stateHistory.clear();
2359 this.webRTCWrapper.restartIce();
2360 }
2361
2362 @Override
2363 public void onRenegotiationNeeded() {
2364 this.webRTCWrapper.execute(this::renegotiate);
2365 }
2366
2367 private void renegotiate() {
2368 final SessionDescription sessionDescription;
2369 try {
2370 sessionDescription = setLocalSessionDescription();
2371 } catch (final Exception e) {
2372 final Throwable cause = Throwables.getRootCause(e);
2373 Log.d(Config.LOGTAG, "failed to renegotiate", cause);
2374 webRTCWrapper.close();
2375 sendSessionTerminate(Reason.FAILED_APPLICATION, cause.getMessage());
2376 return;
2377 }
2378 final RtpContentMap rtpContentMap = RtpContentMap.of(sessionDescription, isInitiator());
2379 final RtpContentMap currentContentMap = getLocalContentMap();
2380 final boolean iceRestart = currentContentMap.iceRestart(rtpContentMap);
2381 final RtpContentMap.Diff diff = currentContentMap.diff(rtpContentMap);
2382
2383 Log.d(
2384 Config.LOGTAG,
2385 id.getAccount().getJid().asBareJid()
2386 + ": renegotiate. iceRestart="
2387 + iceRestart
2388 + " content id diff="
2389 + diff);
2390
2391 if (diff.hasModifications() && iceRestart) {
2392 webRTCWrapper.close();
2393 sendSessionTerminate(
2394 Reason.FAILED_APPLICATION,
2395 "WebRTC unexpectedly tried to modify content and transport at once");
2396 return;
2397 }
2398
2399 if (iceRestart) {
2400 initiateIceRestart(rtpContentMap);
2401 return;
2402 } else if (diff.isEmpty()) {
2403 Log.d(
2404 Config.LOGTAG,
2405 "renegotiation. nothing to do. SignalingState="
2406 + this.webRTCWrapper.getSignalingState());
2407 }
2408
2409 if (diff.added.size() > 0) {
2410 modifyLocalContentMap(rtpContentMap);
2411 sendContentAdd(rtpContentMap, diff.added);
2412 }
2413 }
2414
2415 private void initiateIceRestart(final RtpContentMap rtpContentMap) {
2416 final RtpContentMap transportInfo = rtpContentMap.transportInfo();
2417 final JinglePacket jinglePacket =
2418 transportInfo.toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
2419 Log.d(Config.LOGTAG, "initiating ice restart: " + jinglePacket);
2420 jinglePacket.setTo(id.with);
2421 xmppConnectionService.sendIqPacket(
2422 id.account,
2423 jinglePacket,
2424 (account, response) -> {
2425 if (response.getType() == IqPacket.TYPE.RESULT) {
2426 Log.d(Config.LOGTAG, "received success to our ice restart");
2427 setLocalContentMap(rtpContentMap);
2428 webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
2429 return;
2430 }
2431 if (response.getType() == IqPacket.TYPE.ERROR) {
2432 if (isTieBreak(response)) {
2433 Log.d(Config.LOGTAG, "received tie-break as result of ice restart");
2434 return;
2435 }
2436 handleIqErrorResponse(response);
2437 }
2438 if (response.getType() == IqPacket.TYPE.TIMEOUT) {
2439 handleIqTimeoutResponse(response);
2440 }
2441 });
2442 }
2443
2444 private boolean isTieBreak(final IqPacket response) {
2445 final Element error = response.findChild("error");
2446 return error != null && error.hasChild("tie-break", Namespace.JINGLE_ERRORS);
2447 }
2448
2449 private void sendContentAdd(final RtpContentMap rtpContentMap, final Collection<String> added) {
2450 final RtpContentMap contentAdd = rtpContentMap.toContentModification(added);
2451 this.outgoingContentAdd = contentAdd;
2452 final JinglePacket jinglePacket =
2453 contentAdd.toJinglePacket(JinglePacket.Action.CONTENT_ADD, id.sessionId);
2454 jinglePacket.setTo(id.with);
2455 xmppConnectionService.sendIqPacket(
2456 id.account,
2457 jinglePacket,
2458 (connection, response) -> {
2459 if (response.getType() == IqPacket.TYPE.RESULT) {
2460 Log.d(
2461 Config.LOGTAG,
2462 id.getAccount().getJid().asBareJid()
2463 + ": received ACK to our content-add");
2464 return;
2465 }
2466 if (response.getType() == IqPacket.TYPE.ERROR) {
2467 if (isTieBreak(response)) {
2468 this.outgoingContentAdd = null;
2469 Log.d(Config.LOGTAG, "received tie-break as result of our content-add");
2470 return;
2471 }
2472 handleIqErrorResponse(response);
2473 }
2474 if (response.getType() == IqPacket.TYPE.TIMEOUT) {
2475 handleIqTimeoutResponse(response);
2476 }
2477 });
2478 }
2479
2480 private void setLocalContentMap(final RtpContentMap rtpContentMap) {
2481 if (isInitiator()) {
2482 this.initiatorRtpContentMap = rtpContentMap;
2483 } else {
2484 this.responderRtpContentMap = rtpContentMap;
2485 }
2486 }
2487
2488 private void setRemoteContentMap(final RtpContentMap rtpContentMap) {
2489 if (isInitiator()) {
2490 this.responderRtpContentMap = rtpContentMap;
2491 } else {
2492 this.initiatorRtpContentMap = rtpContentMap;
2493 }
2494 }
2495
2496 // this method is to be used for content map modifications that modify media
2497 private void modifyLocalContentMap(final RtpContentMap rtpContentMap) {
2498 final RtpContentMap activeContents = rtpContentMap.activeContents();
2499 setLocalContentMap(activeContents);
2500 this.webRTCWrapper.switchSpeakerPhonePreference(
2501 AppRTCAudioManager.SpeakerPhonePreference.of(activeContents.getMedia()));
2502 updateEndUserState();
2503 }
2504
2505 private SessionDescription setLocalSessionDescription()
2506 throws ExecutionException, InterruptedException {
2507 final org.webrtc.SessionDescription sessionDescription =
2508 this.webRTCWrapper.setLocalDescription().get();
2509 return SessionDescription.parse(sessionDescription.description);
2510 }
2511
2512 private void closeWebRTCSessionAfterFailedConnection() {
2513 this.webRTCWrapper.close();
2514 synchronized (this) {
2515 if (isTerminated()) {
2516 Log.d(
2517 Config.LOGTAG,
2518 id.account.getJid().asBareJid()
2519 + ": no need to send session-terminate after failed connection. Other party already did");
2520 return;
2521 }
2522 sendSessionTerminate(Reason.CONNECTIVITY_ERROR);
2523 }
2524 }
2525
2526 public boolean zeroDuration() {
2527 return this.sessionDuration.elapsed(TimeUnit.NANOSECONDS) <= 0;
2528 }
2529
2530 public long getCallDuration() {
2531 return this.sessionDuration.elapsed(TimeUnit.MILLISECONDS);
2532 }
2533
2534 public AppRTCAudioManager getAudioManager() {
2535 return webRTCWrapper.getAudioManager();
2536 }
2537
2538 public boolean isMicrophoneEnabled() {
2539 return webRTCWrapper.isMicrophoneEnabled();
2540 }
2541
2542 public boolean setMicrophoneEnabled(final boolean enabled) {
2543 return webRTCWrapper.setMicrophoneEnabled(enabled);
2544 }
2545
2546 public boolean isVideoEnabled() {
2547 return webRTCWrapper.isVideoEnabled();
2548 }
2549
2550 public void setVideoEnabled(final boolean enabled) {
2551 webRTCWrapper.setVideoEnabled(enabled);
2552 }
2553
2554 public boolean isCameraSwitchable() {
2555 return webRTCWrapper.isCameraSwitchable();
2556 }
2557
2558 public boolean isFrontCamera() {
2559 return webRTCWrapper.isFrontCamera();
2560 }
2561
2562 public ListenableFuture<Boolean> switchCamera() {
2563 return webRTCWrapper.switchCamera();
2564 }
2565
2566 @Override
2567 public void onAudioDeviceChanged(
2568 AppRTCAudioManager.AudioDevice selectedAudioDevice,
2569 Set<AppRTCAudioManager.AudioDevice> availableAudioDevices) {
2570 xmppConnectionService.notifyJingleRtpConnectionUpdate(
2571 selectedAudioDevice, availableAudioDevices);
2572 }
2573
2574 private void updateEndUserState() {
2575 final RtpEndUserState endUserState = getEndUserState();
2576 jingleConnectionManager.toneManager.transition(isInitiator(), endUserState, getMedia());
2577 xmppConnectionService.notifyJingleRtpConnectionUpdate(
2578 id.account, id.with, id.sessionId, endUserState);
2579 }
2580
2581 private void updateOngoingCallNotification() {
2582 final State state = this.state;
2583 if (STATES_SHOWING_ONGOING_CALL.contains(state)) {
2584 final boolean reconnecting;
2585 if (state == State.SESSION_ACCEPTED) {
2586 reconnecting =
2587 getPeerConnectionStateAsEndUserState() == RtpEndUserState.RECONNECTING;
2588 } else {
2589 reconnecting = false;
2590 }
2591 xmppConnectionService.setOngoingCall(id, getMedia(), reconnecting);
2592 } else {
2593 xmppConnectionService.removeOngoingCall();
2594 }
2595 }
2596
2597 private void discoverIceServers(final OnIceServersDiscovered onIceServersDiscovered) {
2598 if (id.account.getXmppConnection().getFeatures().externalServiceDiscovery()) {
2599 final IqPacket request = new IqPacket(IqPacket.TYPE.GET);
2600 request.setTo(id.account.getDomain());
2601 request.addChild("services", Namespace.EXTERNAL_SERVICE_DISCOVERY);
2602 xmppConnectionService.sendIqPacket(
2603 id.account,
2604 request,
2605 (account, response) -> {
2606 ImmutableList.Builder<PeerConnection.IceServer> listBuilder =
2607 new ImmutableList.Builder<>();
2608 if (response.getType() == IqPacket.TYPE.RESULT) {
2609 final Element services =
2610 response.findChild(
2611 "services", Namespace.EXTERNAL_SERVICE_DISCOVERY);
2612 final List<Element> children =
2613 services == null
2614 ? Collections.emptyList()
2615 : services.getChildren();
2616 for (final Element child : children) {
2617 if ("service".equals(child.getName())) {
2618 final String type = child.getAttribute("type");
2619 final String host = child.getAttribute("host");
2620 final String sport = child.getAttribute("port");
2621 final Integer port =
2622 sport == null ? null : Ints.tryParse(sport);
2623 final String transport = child.getAttribute("transport");
2624 final String username = child.getAttribute("username");
2625 final String password = child.getAttribute("password");
2626 if (Strings.isNullOrEmpty(host) || port == null) {
2627 continue;
2628 }
2629 if (port < 0 || port > 65535) {
2630 continue;
2631 }
2632 if (Arrays.asList("stun", "stuns", "turn", "turns")
2633 .contains(type)
2634 && Arrays.asList("udp", "tcp").contains(transport)) {
2635 if (Arrays.asList("stuns", "turns").contains(type)
2636 && "udp".equals(transport)) {
2637 Log.d(
2638 Config.LOGTAG,
2639 id.account.getJid().asBareJid()
2640 + ": skipping invalid combination of udp/tls in external services");
2641 continue;
2642 }
2643 final PeerConnection.IceServer.Builder iceServerBuilder =
2644 PeerConnection.IceServer.builder(
2645 String.format(
2646 "%s:%s:%s?transport=%s",
2647 type,
2648 IP.wrapIPv6(host),
2649 port,
2650 transport));
2651 iceServerBuilder.setTlsCertPolicy(
2652 PeerConnection.TlsCertPolicy
2653 .TLS_CERT_POLICY_INSECURE_NO_CHECK);
2654 if (username != null && password != null) {
2655 iceServerBuilder.setUsername(username);
2656 iceServerBuilder.setPassword(password);
2657 } else if (Arrays.asList("turn", "turns").contains(type)) {
2658 // The WebRTC spec requires throwing an
2659 // InvalidAccessError when username (from libwebrtc
2660 // source coder)
2661 // https://chromium.googlesource.com/external/webrtc/+/master/pc/ice_server_parsing.cc
2662 Log.d(
2663 Config.LOGTAG,
2664 id.account.getJid().asBareJid()
2665 + ": skipping "
2666 + type
2667 + "/"
2668 + transport
2669 + " without username and password");
2670 continue;
2671 }
2672 final PeerConnection.IceServer iceServer =
2673 iceServerBuilder.createIceServer();
2674 Log.d(
2675 Config.LOGTAG,
2676 id.account.getJid().asBareJid()
2677 + ": discovered ICE Server: "
2678 + iceServer);
2679 listBuilder.add(iceServer);
2680 }
2681 }
2682 }
2683 }
2684 final List<PeerConnection.IceServer> iceServers = listBuilder.build();
2685 if (iceServers.size() == 0) {
2686 Log.w(
2687 Config.LOGTAG,
2688 id.account.getJid().asBareJid()
2689 + ": no ICE server found "
2690 + response);
2691 }
2692 onIceServersDiscovered.onIceServersDiscovered(iceServers);
2693 });
2694 } else {
2695 Log.w(
2696 Config.LOGTAG,
2697 id.account.getJid().asBareJid() + ": has no external service discovery");
2698 onIceServersDiscovered.onIceServersDiscovered(Collections.emptyList());
2699 }
2700 }
2701
2702 private void finish() {
2703 if (isTerminated()) {
2704 this.cancelRingingTimeout();
2705 this.webRTCWrapper.verifyClosed();
2706 this.jingleConnectionManager.setTerminalSessionState(id, getEndUserState(), getMedia());
2707 this.jingleConnectionManager.finishConnectionOrThrow(this);
2708 try {
2709 File log = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "Cheogram/calls/" + id.getWith().asBareJid() + "." + id.getSessionId() + "." + created + ".log");
2710 log.getParentFile().mkdirs();
2711 Runtime.getRuntime().exec(new String[]{"logcat", "-dT", "" + created + ".0", "-f", log.getAbsolutePath()});
2712 } catch (final IOException e) { }
2713 } else {
2714 throw new IllegalStateException(
2715 String.format("Unable to call finish from %s", this.state));
2716 }
2717 }
2718
2719 private void writeLogMessage(final State state) {
2720 final long duration = getCallDuration();
2721 if (state == State.TERMINATED_SUCCESS
2722 || (state == State.TERMINATED_CONNECTIVITY_ERROR && duration > 0)) {
2723 writeLogMessageSuccess(duration);
2724 } else {
2725 writeLogMessageMissed();
2726 }
2727 }
2728
2729 private void writeLogMessageSuccess(final long duration) {
2730 this.message.setBody(new RtpSessionStatus(true, duration).toString());
2731 this.writeMessage();
2732 }
2733
2734 private void writeLogMessageMissed() {
2735 this.message.setBody(new RtpSessionStatus(false, 0).toString());
2736 this.writeMessage();
2737 }
2738
2739 private void writeMessage() {
2740 final Conversational conversational = message.getConversation();
2741 if (conversational instanceof Conversation) {
2742 ((Conversation) conversational).add(this.message);
2743 xmppConnectionService.createMessageAsync(message);
2744 xmppConnectionService.updateConversationUi();
2745 } else {
2746 throw new IllegalStateException("Somehow the conversation in a message was a stub");
2747 }
2748 }
2749
2750 public State getState() {
2751 return this.state;
2752 }
2753
2754 boolean isTerminated() {
2755 return TERMINATED.contains(this.state);
2756 }
2757
2758 public Optional<VideoTrack> getLocalVideoTrack() {
2759 return webRTCWrapper.getLocalVideoTrack();
2760 }
2761
2762 public Optional<VideoTrack> getRemoteVideoTrack() {
2763 return webRTCWrapper.getRemoteVideoTrack();
2764 }
2765
2766 public EglBase.Context getEglBaseContext() {
2767 return webRTCWrapper.getEglBaseContext();
2768 }
2769
2770 void setProposedMedia(final Set<Media> media) {
2771 this.proposedMedia = media;
2772 }
2773
2774 public void fireStateUpdate() {
2775 final RtpEndUserState endUserState = getEndUserState();
2776 xmppConnectionService.notifyJingleRtpConnectionUpdate(
2777 id.account, id.with, id.sessionId, endUserState);
2778 }
2779
2780 private interface OnIceServersDiscovered {
2781 void onIceServersDiscovered(List<PeerConnection.IceServer> iceServers);
2782 }
2783}