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