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            } else {
 866                acceptContentAdd(contentAddition, incomingContentAdd);
 867            }
 868        } else {
 869            throw new IllegalStateException(
 870                    "Accepted content add does not match pending content-add");
 871        }
 872    }
 873
 874    private void acceptContentAdd(
 875            @NonNull final Set<ContentAddition.Summary> contentAddition,
 876            final RtpContentMap incomingContentAdd) {
 877        final IceUdpTransportInfo.Setup setup = getPeerDtlsSetup();
 878        final RtpContentMap modifiedContentMap =
 879                getRemoteContentMap().addContent(incomingContentAdd, setup);
 880        this.setRemoteContentMap(modifiedContentMap);
 881
 882        final SessionDescription offer;
 883        try {
 884            offer = SessionDescription.of(modifiedContentMap, !isInitiator());
 885        } catch (final IllegalArgumentException | NullPointerException e) {
 886            Log.d(
 887                    Config.LOGTAG,
 888                    id.getAccount().getJid().asBareJid()
 889                            + ": unable convert offer from content-add to SDP",
 890                    e);
 891            webRTCWrapper.close();
 892            sendSessionTerminate(Reason.FAILED_APPLICATION, e.getMessage());
 893            return;
 894        }
 895        this.incomingContentAdd = null;
 896        acceptContentAdd(contentAddition, offer);
 897    }
 898
 899    private void acceptContentAdd(
 900            final Set<ContentAddition.Summary> contentAddition, final SessionDescription offer) {
 901        final org.webrtc.SessionDescription sdp =
 902                new org.webrtc.SessionDescription(
 903                        org.webrtc.SessionDescription.Type.OFFER, offer.toString());
 904        try {
 905            this.webRTCWrapper.setRemoteDescription(sdp).get();
 906
 907            // TODO add tracks for 'media' where contentAddition.senders matches
 908
 909            // TODO if senders.sending(isInitiator())
 910
 911            this.webRTCWrapper.addTrack(Media.VIDEO);
 912
 913            // TODO add additional transceivers for recv only cases
 914
 915            final SessionDescription answer = setLocalSessionDescription();
 916            final RtpContentMap rtpContentMap = RtpContentMap.of(answer, isInitiator());
 917
 918            final RtpContentMap contentAcceptMap =
 919                    rtpContentMap.toContentModification(
 920                            Collections2.transform(contentAddition, ca -> ca.name));
 921
 922            Log.d(
 923                    Config.LOGTAG,
 924                    id.getAccount().getJid().asBareJid()
 925                            + ": sending content-accept "
 926                            + ContentAddition.summary(contentAcceptMap));
 927
 928            addIceCandidatesFromBlackLog();
 929
 930            modifyLocalContentMap(rtpContentMap);
 931            final ListenableFuture<RtpContentMap> future =
 932                    prepareOutgoingContentMap(contentAcceptMap);
 933            Futures.addCallback(
 934                    future,
 935                    new FutureCallback<>() {
 936                        @Override
 937                        public void onSuccess(final RtpContentMap rtpContentMap) {
 938                            sendContentAccept(rtpContentMap);
 939                            webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
 940                        }
 941
 942                        @Override
 943                        public void onFailure(@NonNull final Throwable throwable) {
 944                            failureToPerformAction(JinglePacket.Action.CONTENT_ACCEPT, throwable);
 945                        }
 946                    },
 947                    MoreExecutors.directExecutor());
 948        } catch (final Exception e) {
 949            Log.d(Config.LOGTAG, "unable to accept content add", Throwables.getRootCause(e));
 950            webRTCWrapper.close();
 951            sendSessionTerminate(Reason.FAILED_APPLICATION);
 952        }
 953    }
 954
 955    private void sendContentAccept(final RtpContentMap contentAcceptMap) {
 956        final JinglePacket jinglePacket =
 957                contentAcceptMap.toJinglePacket(JinglePacket.Action.CONTENT_ACCEPT, id.sessionId);
 958        send(jinglePacket);
 959    }
 960
 961    public synchronized void rejectContentAdd() {
 962        final RtpContentMap incomingContentAdd = this.incomingContentAdd;
 963        if (incomingContentAdd == null) {
 964            throw new IllegalStateException("No incoming content add");
 965        }
 966        this.incomingContentAdd = null;
 967        updateEndUserState();
 968        rejectContentAdd(incomingContentAdd);
 969    }
 970
 971    private void rejectContentAdd(final RtpContentMap contentMap) {
 972        final JinglePacket jinglePacket =
 973                contentMap
 974                        .toStub()
 975                        .toJinglePacket(JinglePacket.Action.CONTENT_REJECT, id.sessionId);
 976        Log.d(
 977                Config.LOGTAG,
 978                id.getAccount().getJid().asBareJid()
 979                        + ": rejecting content "
 980                        + ContentAddition.summary(contentMap));
 981        send(jinglePacket);
 982    }
 983
 984    private boolean checkForIceRestart(
 985            final JinglePacket jinglePacket, final RtpContentMap rtpContentMap) {
 986        final RtpContentMap existing = getRemoteContentMap();
 987        final Set<IceUdpTransportInfo.Credentials> existingCredentials;
 988        final IceUdpTransportInfo.Credentials newCredentials;
 989        try {
 990            existingCredentials = existing.getCredentials();
 991            newCredentials = rtpContentMap.getDistinctCredentials();
 992        } catch (final IllegalStateException e) {
 993            Log.d(Config.LOGTAG, "unable to gather credentials for comparison", e);
 994            return false;
 995        }
 996        if (existingCredentials.contains(newCredentials)) {
 997            return false;
 998        }
 999        // TODO an alternative approach is to check if we already got an iq result to our
1000        // ICE-restart
1001        // and if that's the case we are seeing an answer.
1002        // This might be more spec compliant but also more error prone potentially
1003        final boolean isSignalStateStable =
1004                this.webRTCWrapper.getSignalingState() == PeerConnection.SignalingState.STABLE;
1005        // TODO a stable signal state can be another indicator that we have an offer to restart ICE
1006        final boolean isOffer = rtpContentMap.emptyCandidates();
1007        final RtpContentMap restartContentMap;
1008        try {
1009            if (isOffer) {
1010                Log.d(Config.LOGTAG, "received offer to restart ICE " + newCredentials);
1011                restartContentMap =
1012                        existing.modifiedCredentials(
1013                                newCredentials, IceUdpTransportInfo.Setup.ACTPASS);
1014            } else {
1015                final IceUdpTransportInfo.Setup setup = getPeerDtlsSetup();
1016                Log.d(
1017                        Config.LOGTAG,
1018                        "received confirmation of ICE restart"
1019                                + newCredentials
1020                                + " peer_setup="
1021                                + setup);
1022                // DTLS setup attribute needs to be rewritten to reflect current peer state
1023                // https://groups.google.com/g/discuss-webrtc/c/DfpIMwvUfeM
1024                restartContentMap = existing.modifiedCredentials(newCredentials, setup);
1025            }
1026            if (applyIceRestart(jinglePacket, restartContentMap, isOffer)) {
1027                return isOffer;
1028            } else {
1029                Log.d(Config.LOGTAG, "ignoring ICE restart. sending tie-break");
1030                respondWithTieBreak(jinglePacket);
1031                return true;
1032            }
1033        } catch (final Exception exception) {
1034            respondOk(jinglePacket);
1035            final Throwable rootCause = Throwables.getRootCause(exception);
1036            if (rootCause instanceof WebRTCWrapper.PeerConnectionNotInitialized) {
1037                // If this happens a termination is already in progress
1038                Log.d(Config.LOGTAG, "ignoring PeerConnectionNotInitialized on ICE restart");
1039                return true;
1040            }
1041            Log.d(Config.LOGTAG, "failure to apply ICE restart", rootCause);
1042            webRTCWrapper.close();
1043            sendSessionTerminate(Reason.ofThrowable(rootCause), rootCause.getMessage());
1044            return true;
1045        }
1046    }
1047
1048    private IceUdpTransportInfo.Setup getPeerDtlsSetup() {
1049        final IceUdpTransportInfo.Setup peerSetup = this.peerDtlsSetup;
1050        if (peerSetup == null || peerSetup == IceUdpTransportInfo.Setup.ACTPASS) {
1051            throw new IllegalStateException("Invalid peer setup");
1052        }
1053        return peerSetup;
1054    }
1055
1056    private void storePeerDtlsSetup(final IceUdpTransportInfo.Setup setup) {
1057        if (setup == null || setup == IceUdpTransportInfo.Setup.ACTPASS) {
1058            throw new IllegalArgumentException("Trying to store invalid peer dtls setup");
1059        }
1060        this.peerDtlsSetup = setup;
1061    }
1062
1063    private boolean applyIceRestart(
1064            final JinglePacket jinglePacket,
1065            final RtpContentMap restartContentMap,
1066            final boolean isOffer)
1067            throws ExecutionException, InterruptedException {
1068        final SessionDescription sessionDescription =
1069                SessionDescription.of(restartContentMap, !isInitiator());
1070        final org.webrtc.SessionDescription.Type type =
1071                isOffer
1072                        ? org.webrtc.SessionDescription.Type.OFFER
1073                        : org.webrtc.SessionDescription.Type.ANSWER;
1074        org.webrtc.SessionDescription sdp =
1075                new org.webrtc.SessionDescription(type, sessionDescription.toString());
1076        if (isOffer && webRTCWrapper.getSignalingState() != PeerConnection.SignalingState.STABLE) {
1077            if (isInitiator()) {
1078                // We ignore the offer and respond with tie-break. This will clause the responder
1079                // not to apply the content map
1080                return false;
1081            }
1082        }
1083        webRTCWrapper.setRemoteDescription(sdp).get();
1084        setRemoteContentMap(restartContentMap);
1085        if (isOffer) {
1086            final SessionDescription localSessionDescription = setLocalSessionDescription();
1087            setLocalContentMap(RtpContentMap.of(localSessionDescription, isInitiator()));
1088            // We need to respond OK before sending any candidates
1089            respondOk(jinglePacket);
1090            webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
1091        } else {
1092            storePeerDtlsSetup(restartContentMap.getDtlsSetup());
1093        }
1094        return true;
1095    }
1096
1097    private void processCandidates(
1098            final Set<Map.Entry<String, RtpContentMap.DescriptionTransport>> contents) {
1099        for (final Map.Entry<String, RtpContentMap.DescriptionTransport> content : contents) {
1100            processCandidate(content);
1101        }
1102    }
1103
1104    private void processCandidate(
1105            final Map.Entry<String, RtpContentMap.DescriptionTransport> content) {
1106        final RtpContentMap rtpContentMap = getRemoteContentMap();
1107        final List<String> indices = toIdentificationTags(rtpContentMap);
1108        final String sdpMid = content.getKey(); // aka content name
1109        final IceUdpTransportInfo transport = content.getValue().transport;
1110        final IceUdpTransportInfo.Credentials credentials = transport.getCredentials();
1111
1112        // TODO check that credentials remained the same
1113
1114        for (final IceUdpTransportInfo.Candidate candidate : transport.getCandidates()) {
1115            final String sdp;
1116            try {
1117                sdp = candidate.toSdpAttribute(credentials.ufrag);
1118            } catch (final IllegalArgumentException e) {
1119                Log.d(
1120                        Config.LOGTAG,
1121                        id.account.getJid().asBareJid()
1122                                + ": ignoring invalid ICE candidate "
1123                                + e.getMessage());
1124                continue;
1125            }
1126            final int mLineIndex = indices.indexOf(sdpMid);
1127            if (mLineIndex < 0) {
1128                Log.w(
1129                        Config.LOGTAG,
1130                        "mLineIndex not found for " + sdpMid + ". available indices " + indices);
1131            }
1132            final IceCandidate iceCandidate = new IceCandidate(sdpMid, mLineIndex, sdp);
1133            Log.d(Config.LOGTAG, "received candidate: " + iceCandidate);
1134            this.webRTCWrapper.addIceCandidate(iceCandidate);
1135        }
1136    }
1137
1138    private RtpContentMap getRemoteContentMap() {
1139        return isInitiator() ? this.responderRtpContentMap : this.initiatorRtpContentMap;
1140    }
1141
1142    private RtpContentMap getLocalContentMap() {
1143        return isInitiator() ? this.initiatorRtpContentMap : this.responderRtpContentMap;
1144    }
1145
1146    private List<String> toIdentificationTags(final RtpContentMap rtpContentMap) {
1147        final Group originalGroup = rtpContentMap.group;
1148        final List<String> identificationTags =
1149                originalGroup == null
1150                        ? rtpContentMap.getNames()
1151                        : originalGroup.getIdentificationTags();
1152        if (identificationTags.size() == 0) {
1153            Log.w(
1154                    Config.LOGTAG,
1155                    id.account.getJid().asBareJid()
1156                            + ": no identification tags found in initial offer. we won't be able to calculate mLineIndices");
1157        }
1158        return identificationTags;
1159    }
1160
1161    private ListenableFuture<RtpContentMap> receiveRtpContentMap(
1162            final JinglePacket jinglePacket, final boolean expectVerification) {
1163        try {
1164            return receiveRtpContentMap(RtpContentMap.of(jinglePacket), expectVerification);
1165        } catch (final Exception e) {
1166            return Futures.immediateFailedFuture(e);
1167        }
1168    }
1169
1170    private ListenableFuture<RtpContentMap> receiveRtpContentMap(
1171            final RtpContentMap receivedContentMap, final boolean expectVerification) {
1172        Log.d(
1173                Config.LOGTAG,
1174                "receiveRtpContentMap("
1175                        + receivedContentMap.getClass().getSimpleName()
1176                        + ",expectVerification="
1177                        + expectVerification
1178                        + ")");
1179        if (receivedContentMap instanceof OmemoVerifiedRtpContentMap) {
1180            final ListenableFuture<AxolotlService.OmemoVerifiedPayload<RtpContentMap>> future =
1181                    id.account
1182                            .getAxolotlService()
1183                            .decrypt((OmemoVerifiedRtpContentMap) receivedContentMap, id.with);
1184            return Futures.transform(
1185                    future,
1186                    omemoVerifiedPayload -> {
1187                        // TODO test if an exception here triggers a correct abort
1188                        omemoVerification.setOrEnsureEqual(omemoVerifiedPayload);
1189                        Log.d(
1190                                Config.LOGTAG,
1191                                id.account.getJid().asBareJid()
1192                                        + ": received verifiable DTLS fingerprint via "
1193                                        + omemoVerification);
1194                        return omemoVerifiedPayload.getPayload();
1195                    },
1196                    MoreExecutors.directExecutor());
1197        } else if (Config.REQUIRE_RTP_VERIFICATION || expectVerification) {
1198            return Futures.immediateFailedFuture(
1199                    new SecurityException("DTLS fingerprint was unexpectedly not verifiable"));
1200        } else {
1201            return Futures.immediateFuture(receivedContentMap);
1202        }
1203    }
1204
1205    private void receiveSessionInitiate(final JinglePacket jinglePacket) {
1206        if (isInitiator()) {
1207            Log.d(
1208                    Config.LOGTAG,
1209                    String.format(
1210                            "%s: received session-initiate even though we were initiating",
1211                            id.account.getJid().asBareJid()));
1212            if (isTerminated()) {
1213                Log.d(
1214                        Config.LOGTAG,
1215                        String.format(
1216                                "%s: got a reason to terminate with out-of-order. but already in state %s",
1217                                id.account.getJid().asBareJid(), getState()));
1218                respondWithOutOfOrder(jinglePacket);
1219            } else {
1220                terminateWithOutOfOrder(jinglePacket);
1221            }
1222            return;
1223        }
1224        final ListenableFuture<RtpContentMap> future = receiveRtpContentMap(jinglePacket, false);
1225        Futures.addCallback(
1226                future,
1227                new FutureCallback<>() {
1228                    @Override
1229                    public void onSuccess(@Nullable RtpContentMap rtpContentMap) {
1230                        receiveSessionInitiate(jinglePacket, rtpContentMap);
1231                    }
1232
1233                    @Override
1234                    public void onFailure(@NonNull final Throwable throwable) {
1235                        respondOk(jinglePacket);
1236                        sendSessionTerminate(Reason.ofThrowable(throwable), throwable.getMessage());
1237                    }
1238                },
1239                MoreExecutors.directExecutor());
1240    }
1241
1242    private void receiveSessionInitiate(
1243            final JinglePacket jinglePacket, final RtpContentMap contentMap) {
1244        try {
1245            contentMap.requireContentDescriptions();
1246            contentMap.requireDTLSFingerprint(true);
1247        } catch (final RuntimeException e) {
1248            Log.d(
1249                    Config.LOGTAG,
1250                    id.account.getJid().asBareJid() + ": improperly formatted contents",
1251                    Throwables.getRootCause(e));
1252            respondOk(jinglePacket);
1253            sendSessionTerminate(Reason.of(e), e.getMessage());
1254            return;
1255        }
1256        Log.d(
1257                Config.LOGTAG,
1258                "processing session-init with " + contentMap.contents.size() + " contents");
1259        final State target;
1260        if (this.state == State.PROCEED) {
1261            Preconditions.checkState(
1262                    proposedMedia != null && proposedMedia.size() > 0,
1263                    "proposed media must be set when processing pre-approved session-initiate");
1264            if (!this.proposedMedia.equals(contentMap.getMedia())) {
1265                sendSessionTerminate(
1266                        Reason.SECURITY_ERROR,
1267                        String.format(
1268                                "Your session proposal (Jingle Message Initiation) included media %s but your session-initiate was %s",
1269                                this.proposedMedia, contentMap.getMedia()));
1270                return;
1271            }
1272            target = State.SESSION_INITIALIZED_PRE_APPROVED;
1273        } else {
1274            target = State.SESSION_INITIALIZED;
1275        }
1276        if (transition(target, () -> this.initiatorRtpContentMap = contentMap)) {
1277            respondOk(jinglePacket);
1278            pendingIceCandidates.addAll(contentMap.contents.entrySet());
1279            if (target == State.SESSION_INITIALIZED_PRE_APPROVED) {
1280                Log.d(
1281                        Config.LOGTAG,
1282                        id.account.getJid().asBareJid()
1283                                + ": automatically accepting session-initiate");
1284                sendSessionAccept();
1285            } else {
1286                Log.d(
1287                        Config.LOGTAG,
1288                        id.account.getJid().asBareJid()
1289                                + ": received not pre-approved session-initiate. start ringing");
1290                startRinging();
1291            }
1292        } else {
1293            Log.d(
1294                    Config.LOGTAG,
1295                    String.format(
1296                            "%s: received session-initiate while in state %s",
1297                            id.account.getJid().asBareJid(), state));
1298            terminateWithOutOfOrder(jinglePacket);
1299        }
1300    }
1301
1302    private void receiveSessionAccept(final JinglePacket jinglePacket) {
1303        if (!isInitiator()) {
1304            Log.d(
1305                    Config.LOGTAG,
1306                    String.format(
1307                            "%s: received session-accept even though we were responding",
1308                            id.account.getJid().asBareJid()));
1309            terminateWithOutOfOrder(jinglePacket);
1310            return;
1311        }
1312        final ListenableFuture<RtpContentMap> future =
1313                receiveRtpContentMap(jinglePacket, this.omemoVerification.hasFingerprint());
1314        Futures.addCallback(
1315                future,
1316                new FutureCallback<>() {
1317                    @Override
1318                    public void onSuccess(@Nullable RtpContentMap rtpContentMap) {
1319                        receiveSessionAccept(jinglePacket, rtpContentMap);
1320                    }
1321
1322                    @Override
1323                    public void onFailure(@NonNull final Throwable throwable) {
1324                        respondOk(jinglePacket);
1325                        Log.d(
1326                                Config.LOGTAG,
1327                                id.account.getJid().asBareJid()
1328                                        + ": improperly formatted contents in session-accept",
1329                                throwable);
1330                        webRTCWrapper.close();
1331                        sendSessionTerminate(Reason.ofThrowable(throwable), throwable.getMessage());
1332                    }
1333                },
1334                MoreExecutors.directExecutor());
1335    }
1336
1337    private void receiveSessionAccept(
1338            final JinglePacket jinglePacket, final RtpContentMap contentMap) {
1339        try {
1340            contentMap.requireContentDescriptions();
1341            contentMap.requireDTLSFingerprint();
1342        } catch (final RuntimeException e) {
1343            respondOk(jinglePacket);
1344            Log.d(
1345                    Config.LOGTAG,
1346                    id.account.getJid().asBareJid()
1347                            + ": improperly formatted contents in session-accept",
1348                    e);
1349            webRTCWrapper.close();
1350            sendSessionTerminate(Reason.of(e), e.getMessage());
1351            return;
1352        }
1353        final Set<Media> initiatorMedia = this.initiatorRtpContentMap.getMedia();
1354        if (!initiatorMedia.equals(contentMap.getMedia())) {
1355            sendSessionTerminate(
1356                    Reason.SECURITY_ERROR,
1357                    String.format(
1358                            "Your session-included included media %s but our session-initiate was %s",
1359                            this.proposedMedia, contentMap.getMedia()));
1360            return;
1361        }
1362        Log.d(
1363                Config.LOGTAG,
1364                "processing session-accept with " + contentMap.contents.size() + " contents");
1365        if (transition(State.SESSION_ACCEPTED)) {
1366            respondOk(jinglePacket);
1367            receiveSessionAccept(contentMap);
1368        } else {
1369            Log.d(
1370                    Config.LOGTAG,
1371                    String.format(
1372                            "%s: received session-accept while in state %s",
1373                            id.account.getJid().asBareJid(), state));
1374            respondOk(jinglePacket);
1375        }
1376    }
1377
1378    private void receiveSessionAccept(final RtpContentMap contentMap) {
1379        this.responderRtpContentMap = contentMap;
1380        this.storePeerDtlsSetup(contentMap.getDtlsSetup());
1381        final SessionDescription sessionDescription;
1382        try {
1383            sessionDescription = SessionDescription.of(contentMap, false);
1384        } catch (final IllegalArgumentException | NullPointerException e) {
1385            Log.d(
1386                    Config.LOGTAG,
1387                    id.account.getJid().asBareJid()
1388                            + ": unable convert offer from session-accept to SDP",
1389                    e);
1390            webRTCWrapper.close();
1391            sendSessionTerminate(Reason.FAILED_APPLICATION, e.getMessage());
1392            return;
1393        }
1394        final org.webrtc.SessionDescription answer =
1395                new org.webrtc.SessionDescription(
1396                        org.webrtc.SessionDescription.Type.ANSWER, sessionDescription.toString());
1397        try {
1398            this.webRTCWrapper.setRemoteDescription(answer).get();
1399        } catch (final Exception e) {
1400            Log.d(
1401                    Config.LOGTAG,
1402                    id.account.getJid().asBareJid()
1403                            + ": unable to set remote description after receiving session-accept",
1404                    Throwables.getRootCause(e));
1405            webRTCWrapper.close();
1406            sendSessionTerminate(
1407                    Reason.FAILED_APPLICATION, Throwables.getRootCause(e).getMessage());
1408            return;
1409        }
1410        processCandidates(contentMap.contents.entrySet());
1411    }
1412
1413    private void sendSessionAccept() {
1414        final RtpContentMap rtpContentMap = this.initiatorRtpContentMap;
1415        if (rtpContentMap == null) {
1416            throw new IllegalStateException("initiator RTP Content Map has not been set");
1417        }
1418        final SessionDescription offer;
1419        try {
1420            offer = SessionDescription.of(rtpContentMap, true);
1421        } catch (final IllegalArgumentException | NullPointerException e) {
1422            Log.d(
1423                    Config.LOGTAG,
1424                    id.account.getJid().asBareJid()
1425                            + ": unable convert offer from session-initiate to SDP",
1426                    e);
1427            webRTCWrapper.close();
1428            sendSessionTerminate(Reason.FAILED_APPLICATION, e.getMessage());
1429            return;
1430        }
1431        sendSessionAccept(rtpContentMap.getMedia(), offer);
1432    }
1433
1434    private void sendSessionAccept(final Set<Media> media, final SessionDescription offer) {
1435        discoverIceServers(iceServers -> sendSessionAccept(media, offer, iceServers));
1436    }
1437
1438    private synchronized void sendSessionAccept(
1439            final Set<Media> media,
1440            final SessionDescription offer,
1441            final List<PeerConnection.IceServer> iceServers) {
1442        if (isTerminated()) {
1443            Log.w(
1444                    Config.LOGTAG,
1445                    id.account.getJid().asBareJid()
1446                            + ": ICE servers got discovered when session was already terminated. nothing to do.");
1447            return;
1448        }
1449        final boolean includeCandidates = remoteHasSdpOfferAnswer();
1450        try {
1451            setupWebRTC(media, iceServers, !includeCandidates);
1452        } catch (final WebRTCWrapper.InitializationException e) {
1453            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to initialize WebRTC");
1454            webRTCWrapper.close();
1455            sendSessionTerminate(Reason.FAILED_APPLICATION, e.getMessage());
1456            return;
1457        }
1458        final org.webrtc.SessionDescription sdp =
1459                new org.webrtc.SessionDescription(
1460                        org.webrtc.SessionDescription.Type.OFFER, offer.toString());
1461        try {
1462            this.webRTCWrapper.setRemoteDescription(sdp).get();
1463            addIceCandidatesFromBlackLog();
1464            org.webrtc.SessionDescription webRTCSessionDescription =
1465                    this.webRTCWrapper.setLocalDescription(includeCandidates).get();
1466            prepareSessionAccept(webRTCSessionDescription, includeCandidates);
1467        } catch (final Exception e) {
1468            failureToAcceptSession(e);
1469        }
1470    }
1471
1472    private void failureToAcceptSession(final Throwable throwable) {
1473        if (isTerminated()) {
1474            return;
1475        }
1476        final Throwable rootCause = Throwables.getRootCause(throwable);
1477        Log.d(Config.LOGTAG, "unable to send session accept", rootCause);
1478        webRTCWrapper.close();
1479        sendSessionTerminate(Reason.ofThrowable(rootCause), rootCause.getMessage());
1480    }
1481
1482    private void failureToPerformAction(
1483            final JinglePacket.Action action, final Throwable throwable) {
1484        if (isTerminated()) {
1485            return;
1486        }
1487        final Throwable rootCause = Throwables.getRootCause(throwable);
1488        Log.d(Config.LOGTAG, "unable to send " + action, rootCause);
1489        webRTCWrapper.close();
1490        sendSessionTerminate(Reason.ofThrowable(rootCause), rootCause.getMessage());
1491    }
1492
1493    private void addIceCandidatesFromBlackLog() {
1494        Map.Entry<String, RtpContentMap.DescriptionTransport> foo;
1495        while ((foo = this.pendingIceCandidates.poll()) != null) {
1496            processCandidate(foo);
1497            Log.d(
1498                    Config.LOGTAG,
1499                    id.account.getJid().asBareJid() + ": added candidate from back log");
1500        }
1501    }
1502
1503    private void prepareSessionAccept(
1504            final org.webrtc.SessionDescription webRTCSessionDescription,
1505            final boolean includeCandidates) {
1506        final SessionDescription sessionDescription =
1507                SessionDescription.parse(webRTCSessionDescription.description);
1508        final RtpContentMap respondingRtpContentMap = RtpContentMap.of(sessionDescription, false);
1509        final ImmutableMultimap<String, IceUdpTransportInfo.Candidate> candidates;
1510        if (includeCandidates) {
1511            candidates = parseCandidates(sessionDescription);
1512        } else {
1513            candidates = ImmutableMultimap.of();
1514        }
1515        this.responderRtpContentMap = respondingRtpContentMap;
1516        storePeerDtlsSetup(respondingRtpContentMap.getDtlsSetup().flip());
1517        final ListenableFuture<RtpContentMap> outgoingContentMapFuture =
1518                prepareOutgoingContentMap(respondingRtpContentMap);
1519        Futures.addCallback(
1520                outgoingContentMapFuture,
1521                new FutureCallback<>() {
1522                    @Override
1523                    public void onSuccess(final RtpContentMap outgoingContentMap) {
1524                        if (includeCandidates) {
1525                            Log.d(
1526                                    Config.LOGTAG,
1527                                    "including "
1528                                            + candidates.size()
1529                                            + " candidates in session accept");
1530                            sendSessionAccept(outgoingContentMap.withCandidates(candidates));
1531                        } else {
1532                            sendSessionAccept(outgoingContentMap);
1533                        }
1534                        webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
1535                    }
1536
1537                    @Override
1538                    public void onFailure(@NonNull Throwable throwable) {
1539                        failureToAcceptSession(throwable);
1540                    }
1541                },
1542                MoreExecutors.directExecutor());
1543    }
1544
1545    private void sendSessionAccept(final RtpContentMap rtpContentMap) {
1546        if (isTerminated()) {
1547            Log.w(
1548                    Config.LOGTAG,
1549                    id.account.getJid().asBareJid()
1550                            + ": preparing session accept was too slow. already terminated. nothing to do.");
1551            return;
1552        }
1553        transitionOrThrow(State.SESSION_ACCEPTED);
1554        final JinglePacket sessionAccept =
1555                rtpContentMap.toJinglePacket(JinglePacket.Action.SESSION_ACCEPT, id.sessionId);
1556        send(sessionAccept);
1557    }
1558
1559    private ListenableFuture<RtpContentMap> prepareOutgoingContentMap(
1560            final RtpContentMap rtpContentMap) {
1561        if (this.omemoVerification.hasDeviceId()) {
1562            ListenableFuture<AxolotlService.OmemoVerifiedPayload<OmemoVerifiedRtpContentMap>>
1563                    verifiedPayloadFuture =
1564                            id.account
1565                                    .getAxolotlService()
1566                                    .encrypt(
1567                                            rtpContentMap,
1568                                            id.with,
1569                                            omemoVerification.getDeviceId());
1570            return Futures.transform(
1571                    verifiedPayloadFuture,
1572                    verifiedPayload -> {
1573                        omemoVerification.setOrEnsureEqual(verifiedPayload);
1574                        return verifiedPayload.getPayload();
1575                    },
1576                    MoreExecutors.directExecutor());
1577        } else {
1578            return Futures.immediateFuture(rtpContentMap);
1579        }
1580    }
1581
1582    synchronized void deliveryMessage(
1583            final Jid from,
1584            final Element message,
1585            final String serverMessageId,
1586            final long timestamp) {
1587        Log.d(
1588                Config.LOGTAG,
1589                id.account.getJid().asBareJid()
1590                        + ": delivered message to JingleRtpConnection "
1591                        + message);
1592        switch (message.getName()) {
1593            case "propose" -> receivePropose(
1594                    from, Propose.upgrade(message), serverMessageId, timestamp);
1595            case "proceed" -> receiveProceed(
1596                    from, Proceed.upgrade(message), serverMessageId, timestamp);
1597            case "retract" -> receiveRetract(from, serverMessageId, timestamp);
1598            case "reject" -> receiveReject(from, serverMessageId, timestamp);
1599            case "accept" -> receiveAccept(from, serverMessageId, timestamp);
1600        }
1601    }
1602
1603    void deliverFailedProceed(final String message) {
1604        Log.d(
1605                Config.LOGTAG,
1606                id.account.getJid().asBareJid()
1607                        + ": receive message error for proceed message ("
1608                        + Strings.nullToEmpty(message)
1609                        + ")");
1610        if (transition(State.TERMINATED_CONNECTIVITY_ERROR)) {
1611            webRTCWrapper.close();
1612            Log.d(
1613                    Config.LOGTAG,
1614                    id.account.getJid().asBareJid() + ": transitioned into connectivity error");
1615            this.finish();
1616        }
1617    }
1618
1619    private void receiveAccept(final Jid from, final String serverMsgId, final long timestamp) {
1620        final boolean originatedFromMyself =
1621                from.asBareJid().equals(id.account.getJid().asBareJid());
1622        if (originatedFromMyself) {
1623            if (transition(State.ACCEPTED)) {
1624                acceptedOnOtherDevice(serverMsgId, timestamp);
1625            } else {
1626                Log.d(
1627                        Config.LOGTAG,
1628                        id.account.getJid().asBareJid()
1629                                + ": unable to transition to accept because already in state="
1630                                + this.state);
1631                Log.d(Config.LOGTAG, id.account.getJid() + ": received accept from " + from);
1632            }
1633        } else {
1634            Log.d(
1635                    Config.LOGTAG,
1636                    id.account.getJid().asBareJid() + ": ignoring 'accept' from " + from);
1637        }
1638    }
1639
1640    private void acceptedOnOtherDevice(final String serverMsgId, final long timestamp) {
1641        if (serverMsgId != null) {
1642            this.message.setServerMsgId(serverMsgId);
1643        }
1644        this.message.setTime(timestamp);
1645        this.message.setCarbon(true); // indicate that call was accepted on other device
1646        this.writeLogMessageSuccess(0);
1647        this.xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
1648        this.finish();
1649    }
1650
1651    private void receiveReject(final Jid from, final String serverMsgId, final long timestamp) {
1652        final boolean originatedFromMyself =
1653                from.asBareJid().equals(id.account.getJid().asBareJid());
1654        // reject from another one of my clients
1655        if (originatedFromMyself) {
1656            receiveRejectFromMyself(serverMsgId, timestamp);
1657        } else if (isInitiator()) {
1658            if (from.equals(id.with)) {
1659                receiveRejectFromResponder();
1660            } else {
1661                Log.d(
1662                        Config.LOGTAG,
1663                        id.account.getJid()
1664                                + ": ignoring reject from "
1665                                + from
1666                                + " for session with "
1667                                + id.with);
1668            }
1669        } else {
1670            Log.d(
1671                    Config.LOGTAG,
1672                    id.account.getJid()
1673                            + ": ignoring reject from "
1674                            + from
1675                            + " for session with "
1676                            + id.with);
1677        }
1678    }
1679
1680    private void receiveRejectFromMyself(String serverMsgId, long timestamp) {
1681        if (transition(State.REJECTED)) {
1682            this.xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
1683            this.finish();
1684            if (serverMsgId != null) {
1685                this.message.setServerMsgId(serverMsgId);
1686            }
1687            this.message.setTime(timestamp);
1688            this.message.setCarbon(true); // indicate that call was rejected on other device
1689            writeLogMessageMissed();
1690        } else {
1691            Log.d(
1692                    Config.LOGTAG,
1693                    "not able to transition into REJECTED because already in " + this.state);
1694        }
1695    }
1696
1697    private void receiveRejectFromResponder() {
1698        if (isInState(State.PROCEED)) {
1699            Log.d(
1700                    Config.LOGTAG,
1701                    id.account.getJid()
1702                            + ": received reject while still in proceed. callee reconsidered");
1703            closeTransitionLogFinish(State.REJECTED_RACED);
1704            return;
1705        }
1706        if (isInState(State.SESSION_INITIALIZED_PRE_APPROVED)) {
1707            Log.d(
1708                    Config.LOGTAG,
1709                    id.account.getJid()
1710                            + ": received reject while in SESSION_INITIATED_PRE_APPROVED. callee reconsidered before receiving session-init");
1711            closeTransitionLogFinish(State.TERMINATED_DECLINED_OR_BUSY);
1712            return;
1713        }
1714        Log.d(
1715                Config.LOGTAG,
1716                id.account.getJid()
1717                        + ": ignoring reject from responder because already in state "
1718                        + this.state);
1719    }
1720
1721    private void receivePropose(
1722            final Jid from, final Propose propose, final String serverMsgId, final long timestamp) {
1723        final boolean originatedFromMyself =
1724                from.asBareJid().equals(id.account.getJid().asBareJid());
1725        if (originatedFromMyself) {
1726            Log.d(
1727                    Config.LOGTAG,
1728                    id.account.getJid().asBareJid() + ": saw proposal from myself. ignoring");
1729        } else if (transition(
1730                State.PROPOSED,
1731                () -> {
1732                    final Collection<RtpDescription> descriptions =
1733                            Collections2.transform(
1734                                    Collections2.filter(
1735                                            propose.getDescriptions(),
1736                                            d -> d instanceof RtpDescription),
1737                                    input -> (RtpDescription) input);
1738                    final Collection<Media> media =
1739                            Collections2.transform(descriptions, RtpDescription::getMedia);
1740                    Preconditions.checkState(
1741                            !media.contains(Media.UNKNOWN),
1742                            "RTP descriptions contain unknown media");
1743                    Log.d(
1744                            Config.LOGTAG,
1745                            id.account.getJid().asBareJid()
1746                                    + ": received session proposal from "
1747                                    + from
1748                                    + " for "
1749                                    + media);
1750                    this.proposedMedia = Sets.newHashSet(media);
1751                })) {
1752            if (serverMsgId != null) {
1753                this.message.setServerMsgId(serverMsgId);
1754            }
1755            this.message.setTime(timestamp);
1756            startRinging();
1757            if (xmppConnectionService.confirmMessages() && id.getContact().showInContactList()) {
1758                sendJingleMessage("ringing");
1759            }
1760        } else {
1761            Log.d(
1762                    Config.LOGTAG,
1763                    id.account.getJid()
1764                            + ": ignoring session proposal because already in "
1765                            + state);
1766        }
1767    }
1768
1769    private void startRinging() {
1770        Log.d(
1771                Config.LOGTAG,
1772                id.account.getJid().asBareJid()
1773                        + ": received call from "
1774                        + id.with
1775                        + ". start ringing");
1776        ringingTimeoutFuture =
1777                jingleConnectionManager.schedule(
1778                        this::ringingTimeout, BUSY_TIME_OUT, TimeUnit.SECONDS);
1779        xmppConnectionService.getNotificationService().startRinging(id, getMedia());
1780    }
1781
1782    private synchronized void ringingTimeout() {
1783        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": timeout reached for ringing");
1784        switch (this.state) {
1785            case PROPOSED -> {
1786                message.markUnread();
1787                rejectCallFromProposed();
1788            }
1789            case SESSION_INITIALIZED -> {
1790                message.markUnread();
1791                rejectCallFromSessionInitiate();
1792            }
1793        }
1794        xmppConnectionService.getNotificationService().pushMissedCallNow(message);
1795    }
1796
1797    private void cancelRingingTimeout() {
1798        final ScheduledFuture<?> future = this.ringingTimeoutFuture;
1799        if (future != null && !future.isCancelled()) {
1800            future.cancel(false);
1801        }
1802    }
1803
1804    private void receiveProceed(
1805            final Jid from, final Proceed proceed, final String serverMsgId, final long timestamp) {
1806        final Set<Media> media =
1807                Preconditions.checkNotNull(
1808                        this.proposedMedia, "Proposed media has to be set before handling proceed");
1809        Preconditions.checkState(media.size() > 0, "Proposed media should not be empty");
1810        if (from.equals(id.with)) {
1811            if (isInitiator()) {
1812                if (transition(State.PROCEED)) {
1813                    if (serverMsgId != null) {
1814                        this.message.setServerMsgId(serverMsgId);
1815                    }
1816                    this.message.setTime(timestamp);
1817                    final Integer remoteDeviceId = proceed.getDeviceId();
1818                    if (isOmemoEnabled()) {
1819                        this.omemoVerification.setDeviceId(remoteDeviceId);
1820                    } else {
1821                        if (remoteDeviceId != null) {
1822                            Log.d(
1823                                    Config.LOGTAG,
1824                                    id.account.getJid().asBareJid()
1825                                            + ": remote party signaled support for OMEMO verification but we have OMEMO disabled");
1826                        }
1827                        this.omemoVerification.setDeviceId(null);
1828                    }
1829                    this.sendSessionInitiate(media, State.SESSION_INITIALIZED_PRE_APPROVED);
1830                } else {
1831                    Log.d(
1832                            Config.LOGTAG,
1833                            String.format(
1834                                    "%s: ignoring proceed because already in %s",
1835                                    id.account.getJid().asBareJid(), this.state));
1836                }
1837            } else {
1838                Log.d(
1839                        Config.LOGTAG,
1840                        String.format(
1841                                "%s: ignoring proceed because we were not initializing",
1842                                id.account.getJid().asBareJid()));
1843            }
1844        } else if (from.asBareJid().equals(id.account.getJid().asBareJid())) {
1845            if (transition(State.ACCEPTED)) {
1846                Log.d(
1847                        Config.LOGTAG,
1848                        id.account.getJid().asBareJid()
1849                                + ": moved session with "
1850                                + id.with
1851                                + " into state accepted after received carbon copied proceed");
1852                acceptedOnOtherDevice(serverMsgId, timestamp);
1853            }
1854        } else {
1855            Log.d(
1856                    Config.LOGTAG,
1857                    String.format(
1858                            "%s: ignoring proceed from %s. was expected from %s",
1859                            id.account.getJid().asBareJid(), from, id.with));
1860        }
1861    }
1862
1863    private void receiveRetract(final Jid from, final String serverMsgId, final long timestamp) {
1864        if (from.equals(id.with)) {
1865            final State target =
1866                    this.state == State.PROCEED ? State.RETRACTED_RACED : State.RETRACTED;
1867            if (transition(target)) {
1868                xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
1869                xmppConnectionService.getNotificationService().pushMissedCallNow(message);
1870                Log.d(
1871                        Config.LOGTAG,
1872                        id.account.getJid().asBareJid()
1873                                + ": session with "
1874                                + id.with
1875                                + " has been retracted (serverMsgId="
1876                                + serverMsgId
1877                                + ")");
1878                if (serverMsgId != null) {
1879                    this.message.setServerMsgId(serverMsgId);
1880                }
1881                this.message.setTime(timestamp);
1882                if (target == State.RETRACTED) {
1883                    this.message.markUnread();
1884                }
1885                writeLogMessageMissed();
1886                finish();
1887            } else {
1888                Log.d(Config.LOGTAG, "ignoring retract because already in " + this.state);
1889            }
1890        } else {
1891            // TODO parse retract from self
1892            Log.d(
1893                    Config.LOGTAG,
1894                    id.account.getJid().asBareJid()
1895                            + ": received retract from "
1896                            + from
1897                            + ". expected retract from"
1898                            + id.with
1899                            + ". ignoring");
1900        }
1901    }
1902
1903    public void sendSessionInitiate() {
1904        sendSessionInitiate(this.proposedMedia, State.SESSION_INITIALIZED);
1905    }
1906
1907    private void sendSessionInitiate(final Set<Media> media, final State targetState) {
1908        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": prepare session-initiate");
1909        discoverIceServers(iceServers -> sendSessionInitiate(media, targetState, iceServers));
1910    }
1911
1912    private synchronized void sendSessionInitiate(
1913            final Set<Media> media,
1914            final State targetState,
1915            final List<PeerConnection.IceServer> iceServers) {
1916        if (isTerminated()) {
1917            Log.w(
1918                    Config.LOGTAG,
1919                    id.account.getJid().asBareJid()
1920                            + ": ICE servers got discovered when session was already terminated. nothing to do.");
1921            return;
1922        }
1923        final boolean includeCandidates = remoteHasSdpOfferAnswer();
1924        try {
1925            setupWebRTC(media, iceServers, !includeCandidates);
1926        } catch (final WebRTCWrapper.InitializationException e) {
1927            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to initialize WebRTC");
1928            webRTCWrapper.close();
1929            sendRetract(Reason.ofThrowable(e));
1930            return;
1931        }
1932        try {
1933            org.webrtc.SessionDescription webRTCSessionDescription =
1934                    this.webRTCWrapper.setLocalDescription(includeCandidates).get();
1935            prepareSessionInitiate(webRTCSessionDescription, includeCandidates, targetState);
1936        } catch (final Exception e) {
1937            // TODO sending the error text is worthwhile as well. Especially for FailureToSet
1938            // exceptions
1939            failureToInitiateSession(e, targetState);
1940        }
1941    }
1942
1943    private void failureToInitiateSession(final Throwable throwable, final State targetState) {
1944        if (isTerminated()) {
1945            return;
1946        }
1947        Log.d(
1948                Config.LOGTAG,
1949                id.account.getJid().asBareJid() + ": unable to sendSessionInitiate",
1950                Throwables.getRootCause(throwable));
1951        webRTCWrapper.close();
1952        final Reason reason = Reason.ofThrowable(throwable);
1953        if (isInState(targetState)) {
1954            sendSessionTerminate(reason, throwable.getMessage());
1955        } else {
1956            sendRetract(reason);
1957        }
1958    }
1959
1960    private void sendRetract(final Reason reason) {
1961        // TODO embed reason into retract
1962        sendJingleMessage("retract", id.with.asBareJid());
1963        transitionOrThrow(reasonToState(reason));
1964        this.finish();
1965    }
1966
1967    private void prepareSessionInitiate(
1968            final org.webrtc.SessionDescription webRTCSessionDescription,
1969            final boolean includeCandidates,
1970            final State targetState) {
1971        final SessionDescription sessionDescription =
1972                SessionDescription.parse(webRTCSessionDescription.description);
1973        final RtpContentMap rtpContentMap = RtpContentMap.of(sessionDescription, true);
1974        final ImmutableMultimap<String, IceUdpTransportInfo.Candidate> candidates;
1975        if (includeCandidates) {
1976            candidates = parseCandidates(sessionDescription);
1977        } else {
1978            candidates = ImmutableMultimap.of();
1979        }
1980        this.initiatorRtpContentMap = rtpContentMap;
1981        final ListenableFuture<RtpContentMap> outgoingContentMapFuture =
1982                encryptSessionInitiate(rtpContentMap);
1983        Futures.addCallback(
1984                outgoingContentMapFuture,
1985                new FutureCallback<>() {
1986                    @Override
1987                    public void onSuccess(final RtpContentMap outgoingContentMap) {
1988                        if (includeCandidates) {
1989                            Log.d(
1990                                    Config.LOGTAG,
1991                                    "including "
1992                                            + candidates.size()
1993                                            + " candidates in session initiate");
1994                            sendSessionInitiate(
1995                                    outgoingContentMap.withCandidates(candidates), targetState);
1996                        } else {
1997                            sendSessionInitiate(outgoingContentMap, targetState);
1998                        }
1999                        webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
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}