JingleRtpConnection.java

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