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                            webRTCWrapper.resetPendingCandidates();
1532                        } else {
1533                            sendSessionAccept(outgoingContentMap);
1534                            webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
1535                        }
1536                    }
1537
1538                    @Override
1539                    public void onFailure(@NonNull Throwable throwable) {
1540                        failureToAcceptSession(throwable);
1541                    }
1542                },
1543                MoreExecutors.directExecutor());
1544    }
1545
1546    private void sendSessionAccept(final RtpContentMap rtpContentMap) {
1547        if (isTerminated()) {
1548            Log.w(
1549                    Config.LOGTAG,
1550                    id.account.getJid().asBareJid()
1551                            + ": preparing session accept was too slow. already terminated. nothing to do.");
1552            return;
1553        }
1554        transitionOrThrow(State.SESSION_ACCEPTED);
1555        final JinglePacket sessionAccept =
1556                rtpContentMap.toJinglePacket(JinglePacket.Action.SESSION_ACCEPT, id.sessionId);
1557        send(sessionAccept);
1558    }
1559
1560    private ListenableFuture<RtpContentMap> prepareOutgoingContentMap(
1561            final RtpContentMap rtpContentMap) {
1562        if (this.omemoVerification.hasDeviceId()) {
1563            ListenableFuture<AxolotlService.OmemoVerifiedPayload<OmemoVerifiedRtpContentMap>>
1564                    verifiedPayloadFuture =
1565                            id.account
1566                                    .getAxolotlService()
1567                                    .encrypt(
1568                                            rtpContentMap,
1569                                            id.with,
1570                                            omemoVerification.getDeviceId());
1571            return Futures.transform(
1572                    verifiedPayloadFuture,
1573                    verifiedPayload -> {
1574                        omemoVerification.setOrEnsureEqual(verifiedPayload);
1575                        return verifiedPayload.getPayload();
1576                    },
1577                    MoreExecutors.directExecutor());
1578        } else {
1579            return Futures.immediateFuture(rtpContentMap);
1580        }
1581    }
1582
1583    synchronized void deliveryMessage(
1584            final Jid from,
1585            final Element message,
1586            final String serverMessageId,
1587            final long timestamp) {
1588        Log.d(
1589                Config.LOGTAG,
1590                id.account.getJid().asBareJid()
1591                        + ": delivered message to JingleRtpConnection "
1592                        + message);
1593        switch (message.getName()) {
1594            case "propose" -> receivePropose(
1595                    from, Propose.upgrade(message), serverMessageId, timestamp);
1596            case "proceed" -> receiveProceed(
1597                    from, Proceed.upgrade(message), serverMessageId, timestamp);
1598            case "retract" -> receiveRetract(from, serverMessageId, timestamp);
1599            case "reject" -> receiveReject(from, serverMessageId, timestamp);
1600            case "accept" -> receiveAccept(from, serverMessageId, timestamp);
1601        }
1602    }
1603
1604    void deliverFailedProceed(final String message) {
1605        Log.d(
1606                Config.LOGTAG,
1607                id.account.getJid().asBareJid()
1608                        + ": receive message error for proceed message ("
1609                        + Strings.nullToEmpty(message)
1610                        + ")");
1611        if (transition(State.TERMINATED_CONNECTIVITY_ERROR)) {
1612            webRTCWrapper.close();
1613            Log.d(
1614                    Config.LOGTAG,
1615                    id.account.getJid().asBareJid() + ": transitioned into connectivity error");
1616            this.finish();
1617        }
1618    }
1619
1620    private void receiveAccept(final Jid from, final String serverMsgId, final long timestamp) {
1621        final boolean originatedFromMyself =
1622                from.asBareJid().equals(id.account.getJid().asBareJid());
1623        if (originatedFromMyself) {
1624            if (transition(State.ACCEPTED)) {
1625                acceptedOnOtherDevice(serverMsgId, timestamp);
1626            } else {
1627                Log.d(
1628                        Config.LOGTAG,
1629                        id.account.getJid().asBareJid()
1630                                + ": unable to transition to accept because already in state="
1631                                + this.state);
1632                Log.d(Config.LOGTAG, id.account.getJid() + ": received accept from " + from);
1633            }
1634        } else {
1635            Log.d(
1636                    Config.LOGTAG,
1637                    id.account.getJid().asBareJid() + ": ignoring 'accept' from " + from);
1638        }
1639    }
1640
1641    private void acceptedOnOtherDevice(final String serverMsgId, final long timestamp) {
1642        if (serverMsgId != null) {
1643            this.message.setServerMsgId(serverMsgId);
1644        }
1645        this.message.setTime(timestamp);
1646        this.message.setCarbon(true); // indicate that call was accepted on other device
1647        this.writeLogMessageSuccess(0);
1648        this.xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
1649        this.finish();
1650    }
1651
1652    private void receiveReject(final Jid from, final String serverMsgId, final long timestamp) {
1653        final boolean originatedFromMyself =
1654                from.asBareJid().equals(id.account.getJid().asBareJid());
1655        // reject from another one of my clients
1656        if (originatedFromMyself) {
1657            receiveRejectFromMyself(serverMsgId, timestamp);
1658        } else if (isInitiator()) {
1659            if (from.equals(id.with)) {
1660                receiveRejectFromResponder();
1661            } else {
1662                Log.d(
1663                        Config.LOGTAG,
1664                        id.account.getJid()
1665                                + ": ignoring reject from "
1666                                + from
1667                                + " for session with "
1668                                + id.with);
1669            }
1670        } else {
1671            Log.d(
1672                    Config.LOGTAG,
1673                    id.account.getJid()
1674                            + ": ignoring reject from "
1675                            + from
1676                            + " for session with "
1677                            + id.with);
1678        }
1679    }
1680
1681    private void receiveRejectFromMyself(String serverMsgId, long timestamp) {
1682        if (transition(State.REJECTED)) {
1683            this.xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
1684            this.finish();
1685            if (serverMsgId != null) {
1686                this.message.setServerMsgId(serverMsgId);
1687            }
1688            this.message.setTime(timestamp);
1689            this.message.setCarbon(true); // indicate that call was rejected on other device
1690            writeLogMessageMissed();
1691        } else {
1692            Log.d(
1693                    Config.LOGTAG,
1694                    "not able to transition into REJECTED because already in " + this.state);
1695        }
1696    }
1697
1698    private void receiveRejectFromResponder() {
1699        if (isInState(State.PROCEED)) {
1700            Log.d(
1701                    Config.LOGTAG,
1702                    id.account.getJid()
1703                            + ": received reject while still in proceed. callee reconsidered");
1704            closeTransitionLogFinish(State.REJECTED_RACED);
1705            return;
1706        }
1707        if (isInState(State.SESSION_INITIALIZED_PRE_APPROVED)) {
1708            Log.d(
1709                    Config.LOGTAG,
1710                    id.account.getJid()
1711                            + ": received reject while in SESSION_INITIATED_PRE_APPROVED. callee reconsidered before receiving session-init");
1712            closeTransitionLogFinish(State.TERMINATED_DECLINED_OR_BUSY);
1713            return;
1714        }
1715        Log.d(
1716                Config.LOGTAG,
1717                id.account.getJid()
1718                        + ": ignoring reject from responder because already in state "
1719                        + this.state);
1720    }
1721
1722    private void receivePropose(
1723            final Jid from, final Propose propose, final String serverMsgId, final long timestamp) {
1724        final boolean originatedFromMyself =
1725                from.asBareJid().equals(id.account.getJid().asBareJid());
1726        if (originatedFromMyself) {
1727            Log.d(
1728                    Config.LOGTAG,
1729                    id.account.getJid().asBareJid() + ": saw proposal from myself. ignoring");
1730        } else if (transition(
1731                State.PROPOSED,
1732                () -> {
1733                    final Collection<RtpDescription> descriptions =
1734                            Collections2.transform(
1735                                    Collections2.filter(
1736                                            propose.getDescriptions(),
1737                                            d -> d instanceof RtpDescription),
1738                                    input -> (RtpDescription) input);
1739                    final Collection<Media> media =
1740                            Collections2.transform(descriptions, RtpDescription::getMedia);
1741                    Preconditions.checkState(
1742                            !media.contains(Media.UNKNOWN),
1743                            "RTP descriptions contain unknown media");
1744                    Log.d(
1745                            Config.LOGTAG,
1746                            id.account.getJid().asBareJid()
1747                                    + ": received session proposal from "
1748                                    + from
1749                                    + " for "
1750                                    + media);
1751                    this.proposedMedia = Sets.newHashSet(media);
1752                })) {
1753            if (serverMsgId != null) {
1754                this.message.setServerMsgId(serverMsgId);
1755            }
1756            this.message.setTime(timestamp);
1757            startRinging();
1758            if (xmppConnectionService.confirmMessages() && id.getContact().showInContactList()) {
1759                sendJingleMessage("ringing");
1760            }
1761        } else {
1762            Log.d(
1763                    Config.LOGTAG,
1764                    id.account.getJid()
1765                            + ": ignoring session proposal because already in "
1766                            + state);
1767        }
1768    }
1769
1770    private void startRinging() {
1771        Log.d(
1772                Config.LOGTAG,
1773                id.account.getJid().asBareJid()
1774                        + ": received call from "
1775                        + id.with
1776                        + ". start ringing");
1777        ringingTimeoutFuture =
1778                jingleConnectionManager.schedule(
1779                        this::ringingTimeout, BUSY_TIME_OUT, TimeUnit.SECONDS);
1780        xmppConnectionService.getNotificationService().startRinging(id, getMedia());
1781    }
1782
1783    private synchronized void ringingTimeout() {
1784        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": timeout reached for ringing");
1785        switch (this.state) {
1786            case PROPOSED -> {
1787                message.markUnread();
1788                rejectCallFromProposed();
1789            }
1790            case SESSION_INITIALIZED -> {
1791                message.markUnread();
1792                rejectCallFromSessionInitiate();
1793            }
1794        }
1795        xmppConnectionService.getNotificationService().pushMissedCallNow(message);
1796    }
1797
1798    private void cancelRingingTimeout() {
1799        final ScheduledFuture<?> future = this.ringingTimeoutFuture;
1800        if (future != null && !future.isCancelled()) {
1801            future.cancel(false);
1802        }
1803    }
1804
1805    private void receiveProceed(
1806            final Jid from, final Proceed proceed, final String serverMsgId, final long timestamp) {
1807        final Set<Media> media =
1808                Preconditions.checkNotNull(
1809                        this.proposedMedia, "Proposed media has to be set before handling proceed");
1810        Preconditions.checkState(media.size() > 0, "Proposed media should not be empty");
1811        if (from.equals(id.with)) {
1812            if (isInitiator()) {
1813                if (transition(State.PROCEED)) {
1814                    if (serverMsgId != null) {
1815                        this.message.setServerMsgId(serverMsgId);
1816                    }
1817                    this.message.setTime(timestamp);
1818                    final Integer remoteDeviceId = proceed.getDeviceId();
1819                    if (isOmemoEnabled()) {
1820                        this.omemoVerification.setDeviceId(remoteDeviceId);
1821                    } else {
1822                        if (remoteDeviceId != null) {
1823                            Log.d(
1824                                    Config.LOGTAG,
1825                                    id.account.getJid().asBareJid()
1826                                            + ": remote party signaled support for OMEMO verification but we have OMEMO disabled");
1827                        }
1828                        this.omemoVerification.setDeviceId(null);
1829                    }
1830                    this.sendSessionInitiate(media, State.SESSION_INITIALIZED_PRE_APPROVED);
1831                } else {
1832                    Log.d(
1833                            Config.LOGTAG,
1834                            String.format(
1835                                    "%s: ignoring proceed because already in %s",
1836                                    id.account.getJid().asBareJid(), this.state));
1837                }
1838            } else {
1839                Log.d(
1840                        Config.LOGTAG,
1841                        String.format(
1842                                "%s: ignoring proceed because we were not initializing",
1843                                id.account.getJid().asBareJid()));
1844            }
1845        } else if (from.asBareJid().equals(id.account.getJid().asBareJid())) {
1846            if (transition(State.ACCEPTED)) {
1847                Log.d(
1848                        Config.LOGTAG,
1849                        id.account.getJid().asBareJid()
1850                                + ": moved session with "
1851                                + id.with
1852                                + " into state accepted after received carbon copied proceed");
1853                acceptedOnOtherDevice(serverMsgId, timestamp);
1854            }
1855        } else {
1856            Log.d(
1857                    Config.LOGTAG,
1858                    String.format(
1859                            "%s: ignoring proceed from %s. was expected from %s",
1860                            id.account.getJid().asBareJid(), from, id.with));
1861        }
1862    }
1863
1864    private void receiveRetract(final Jid from, final String serverMsgId, final long timestamp) {
1865        if (from.equals(id.with)) {
1866            final State target =
1867                    this.state == State.PROCEED ? State.RETRACTED_RACED : State.RETRACTED;
1868            if (transition(target)) {
1869                xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
1870                xmppConnectionService.getNotificationService().pushMissedCallNow(message);
1871                Log.d(
1872                        Config.LOGTAG,
1873                        id.account.getJid().asBareJid()
1874                                + ": session with "
1875                                + id.with
1876                                + " has been retracted (serverMsgId="
1877                                + serverMsgId
1878                                + ")");
1879                if (serverMsgId != null) {
1880                    this.message.setServerMsgId(serverMsgId);
1881                }
1882                this.message.setTime(timestamp);
1883                if (target == State.RETRACTED) {
1884                    this.message.markUnread();
1885                }
1886                writeLogMessageMissed();
1887                finish();
1888            } else {
1889                Log.d(Config.LOGTAG, "ignoring retract because already in " + this.state);
1890            }
1891        } else {
1892            // TODO parse retract from self
1893            Log.d(
1894                    Config.LOGTAG,
1895                    id.account.getJid().asBareJid()
1896                            + ": received retract from "
1897                            + from
1898                            + ". expected retract from"
1899                            + id.with
1900                            + ". ignoring");
1901        }
1902    }
1903
1904    public void sendSessionInitiate() {
1905        sendSessionInitiate(this.proposedMedia, State.SESSION_INITIALIZED);
1906    }
1907
1908    private void sendSessionInitiate(final Set<Media> media, final State targetState) {
1909        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": prepare session-initiate");
1910        discoverIceServers(iceServers -> sendSessionInitiate(media, targetState, iceServers));
1911    }
1912
1913    private synchronized void sendSessionInitiate(
1914            final Set<Media> media,
1915            final State targetState,
1916            final List<PeerConnection.IceServer> iceServers) {
1917        if (isTerminated()) {
1918            Log.w(
1919                    Config.LOGTAG,
1920                    id.account.getJid().asBareJid()
1921                            + ": ICE servers got discovered when session was already terminated. nothing to do.");
1922            return;
1923        }
1924        final boolean includeCandidates = remoteHasSdpOfferAnswer();
1925        try {
1926            setupWebRTC(media, iceServers, !includeCandidates);
1927        } catch (final WebRTCWrapper.InitializationException e) {
1928            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to initialize WebRTC");
1929            webRTCWrapper.close();
1930            sendRetract(Reason.ofThrowable(e));
1931            return;
1932        }
1933        try {
1934            org.webrtc.SessionDescription webRTCSessionDescription =
1935                    this.webRTCWrapper.setLocalDescription(includeCandidates).get();
1936            prepareSessionInitiate(webRTCSessionDescription, includeCandidates, targetState);
1937        } catch (final Exception e) {
1938            // TODO sending the error text is worthwhile as well. Especially for FailureToSet
1939            // exceptions
1940            failureToInitiateSession(e, targetState);
1941        }
1942    }
1943
1944    private void failureToInitiateSession(final Throwable throwable, final State targetState) {
1945        if (isTerminated()) {
1946            return;
1947        }
1948        Log.d(
1949                Config.LOGTAG,
1950                id.account.getJid().asBareJid() + ": unable to sendSessionInitiate",
1951                Throwables.getRootCause(throwable));
1952        webRTCWrapper.close();
1953        final Reason reason = Reason.ofThrowable(throwable);
1954        if (isInState(targetState)) {
1955            sendSessionTerminate(reason, throwable.getMessage());
1956        } else {
1957            sendRetract(reason);
1958        }
1959    }
1960
1961    private void sendRetract(final Reason reason) {
1962        // TODO embed reason into retract
1963        sendJingleMessage("retract", id.with.asBareJid());
1964        transitionOrThrow(reasonToState(reason));
1965        this.finish();
1966    }
1967
1968    private void prepareSessionInitiate(
1969            final org.webrtc.SessionDescription webRTCSessionDescription,
1970            final boolean includeCandidates,
1971            final State targetState) {
1972        final SessionDescription sessionDescription =
1973                SessionDescription.parse(webRTCSessionDescription.description);
1974        final RtpContentMap rtpContentMap = RtpContentMap.of(sessionDescription, true);
1975        final ImmutableMultimap<String, IceUdpTransportInfo.Candidate> candidates;
1976        if (includeCandidates) {
1977            candidates = parseCandidates(sessionDescription);
1978        } else {
1979            candidates = ImmutableMultimap.of();
1980        }
1981        this.initiatorRtpContentMap = rtpContentMap;
1982        final ListenableFuture<RtpContentMap> outgoingContentMapFuture =
1983                encryptSessionInitiate(rtpContentMap);
1984        Futures.addCallback(
1985                outgoingContentMapFuture,
1986                new FutureCallback<>() {
1987                    @Override
1988                    public void onSuccess(final RtpContentMap outgoingContentMap) {
1989                        if (includeCandidates) {
1990                            Log.d(
1991                                    Config.LOGTAG,
1992                                    "including "
1993                                            + candidates.size()
1994                                            + " candidates in session initiate");
1995                            sendSessionInitiate(
1996                                    outgoingContentMap.withCandidates(candidates), targetState);
1997                            webRTCWrapper.resetPendingCandidates();
1998                        } else {
1999                            sendSessionInitiate(outgoingContentMap, targetState);
2000                            webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
2001                        }
2002                    }
2003
2004                    @Override
2005                    public void onFailure(@NonNull final Throwable throwable) {
2006                        failureToInitiateSession(throwable, targetState);
2007                    }
2008                },
2009                MoreExecutors.directExecutor());
2010    }
2011
2012    private void sendSessionInitiate(final RtpContentMap rtpContentMap, final State targetState) {
2013        if (isTerminated()) {
2014            Log.w(
2015                    Config.LOGTAG,
2016                    id.account.getJid().asBareJid()
2017                            + ": preparing session was too slow. already terminated. nothing to do.");
2018            return;
2019        }
2020        this.transitionOrThrow(targetState);
2021        final JinglePacket sessionInitiate =
2022                rtpContentMap.toJinglePacket(JinglePacket.Action.SESSION_INITIATE, id.sessionId);
2023        send(sessionInitiate);
2024    }
2025
2026    private ListenableFuture<RtpContentMap> encryptSessionInitiate(
2027            final RtpContentMap rtpContentMap) {
2028        if (this.omemoVerification.hasDeviceId()) {
2029            final ListenableFuture<AxolotlService.OmemoVerifiedPayload<OmemoVerifiedRtpContentMap>>
2030                    verifiedPayloadFuture =
2031                            id.account
2032                                    .getAxolotlService()
2033                                    .encrypt(
2034                                            rtpContentMap,
2035                                            id.with,
2036                                            omemoVerification.getDeviceId());
2037            final ListenableFuture<RtpContentMap> future =
2038                    Futures.transform(
2039                            verifiedPayloadFuture,
2040                            verifiedPayload -> {
2041                                omemoVerification.setSessionFingerprint(
2042                                        verifiedPayload.getFingerprint());
2043                                return verifiedPayload.getPayload();
2044                            },
2045                            MoreExecutors.directExecutor());
2046            if (Config.REQUIRE_RTP_VERIFICATION) {
2047                return future;
2048            }
2049            return Futures.catching(
2050                    future,
2051                    CryptoFailedException.class,
2052                    e -> {
2053                        Log.w(
2054                                Config.LOGTAG,
2055                                id.account.getJid().asBareJid()
2056                                        + ": unable to use OMEMO DTLS verification on outgoing session initiate. falling back",
2057                                e);
2058                        return rtpContentMap;
2059                    },
2060                    MoreExecutors.directExecutor());
2061        } else {
2062            return Futures.immediateFuture(rtpContentMap);
2063        }
2064    }
2065
2066    private void sendSessionTerminate(final Reason reason) {
2067        sendSessionTerminate(reason, null);
2068    }
2069
2070    private void sendSessionTerminate(final Reason reason, final String text) {
2071        final State previous = this.state;
2072        final State target = reasonToState(reason);
2073        transitionOrThrow(target);
2074        if (previous != State.NULL) {
2075            writeLogMessage(target);
2076        }
2077        final JinglePacket jinglePacket =
2078                new JinglePacket(JinglePacket.Action.SESSION_TERMINATE, id.sessionId);
2079        jinglePacket.setReason(reason, text);
2080        send(jinglePacket);
2081        finish();
2082    }
2083
2084    private void sendTransportInfo(
2085            final String contentName, IceUdpTransportInfo.Candidate candidate) {
2086        final RtpContentMap transportInfo;
2087        try {
2088            final RtpContentMap rtpContentMap =
2089                    isInitiator() ? this.initiatorRtpContentMap : this.responderRtpContentMap;
2090            transportInfo = rtpContentMap.transportInfo(contentName, candidate);
2091        } catch (final Exception e) {
2092            Log.d(
2093                    Config.LOGTAG,
2094                    id.account.getJid().asBareJid()
2095                            + ": unable to prepare transport-info from candidate for content="
2096                            + contentName);
2097            return;
2098        }
2099        final JinglePacket jinglePacket =
2100                transportInfo.toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
2101        send(jinglePacket);
2102    }
2103
2104    private void send(final JinglePacket jinglePacket) {
2105        jinglePacket.setTo(id.with);
2106        xmppConnectionService.sendIqPacket(id.account, jinglePacket, this::handleIqResponse);
2107    }
2108
2109    private synchronized void handleIqResponse(final Account account, final IqPacket response) {
2110        if (response.getType() == IqPacket.TYPE.ERROR) {
2111            handleIqErrorResponse(response);
2112            return;
2113        }
2114        if (response.getType() == IqPacket.TYPE.TIMEOUT) {
2115            handleIqTimeoutResponse(response);
2116        }
2117    }
2118
2119    private void handleIqErrorResponse(final IqPacket response) {
2120        Preconditions.checkArgument(response.getType() == IqPacket.TYPE.ERROR);
2121        final String errorCondition = response.getErrorCondition();
2122        Log.d(
2123                Config.LOGTAG,
2124                id.account.getJid().asBareJid()
2125                        + ": received IQ-error from "
2126                        + response.getFrom()
2127                        + " in RTP session. "
2128                        + errorCondition);
2129        if (isTerminated()) {
2130            Log.i(
2131                    Config.LOGTAG,
2132                    id.account.getJid().asBareJid()
2133                            + ": ignoring error because session was already terminated");
2134            return;
2135        }
2136        this.webRTCWrapper.close();
2137        final State target;
2138        if (Arrays.asList(
2139                        "service-unavailable",
2140                        "recipient-unavailable",
2141                        "remote-server-not-found",
2142                        "remote-server-timeout")
2143                .contains(errorCondition)) {
2144            target = State.TERMINATED_CONNECTIVITY_ERROR;
2145        } else {
2146            target = State.TERMINATED_APPLICATION_FAILURE;
2147        }
2148        transitionOrThrow(target);
2149        this.finish();
2150    }
2151
2152    private void handleIqTimeoutResponse(final IqPacket response) {
2153        Preconditions.checkArgument(response.getType() == IqPacket.TYPE.TIMEOUT);
2154        Log.d(
2155                Config.LOGTAG,
2156                id.account.getJid().asBareJid()
2157                        + ": received IQ timeout in RTP session with "
2158                        + id.with
2159                        + ". terminating with connectivity error");
2160        if (isTerminated()) {
2161            Log.i(
2162                    Config.LOGTAG,
2163                    id.account.getJid().asBareJid()
2164                            + ": ignoring error because session was already terminated");
2165            return;
2166        }
2167        this.webRTCWrapper.close();
2168        transitionOrThrow(State.TERMINATED_CONNECTIVITY_ERROR);
2169        this.finish();
2170    }
2171
2172    private void terminateWithOutOfOrder(final JinglePacket jinglePacket) {
2173        Log.d(
2174                Config.LOGTAG,
2175                id.account.getJid().asBareJid() + ": terminating session with out-of-order");
2176        this.webRTCWrapper.close();
2177        transitionOrThrow(State.TERMINATED_APPLICATION_FAILURE);
2178        respondWithOutOfOrder(jinglePacket);
2179        this.finish();
2180    }
2181
2182    private void respondWithTieBreak(final JinglePacket jinglePacket) {
2183        respondWithJingleError(jinglePacket, "tie-break", "conflict", "cancel");
2184    }
2185
2186    private void respondWithOutOfOrder(final JinglePacket jinglePacket) {
2187        respondWithJingleError(jinglePacket, "out-of-order", "unexpected-request", "wait");
2188    }
2189
2190    private void respondWithItemNotFound(final JinglePacket jinglePacket) {
2191        respondWithJingleError(jinglePacket, null, "item-not-found", "cancel");
2192    }
2193
2194    void respondWithJingleError(
2195            final IqPacket original,
2196            String jingleCondition,
2197            String condition,
2198            String conditionType) {
2199        jingleConnectionManager.respondWithJingleError(
2200                id.account, original, jingleCondition, condition, conditionType);
2201    }
2202
2203    private void respondOk(final JinglePacket jinglePacket) {
2204        xmppConnectionService.sendIqPacket(
2205                id.account, jinglePacket.generateResponse(IqPacket.TYPE.RESULT), null);
2206    }
2207
2208    public RtpEndUserState getEndUserState() {
2209        switch (this.state) {
2210            case NULL, PROPOSED, SESSION_INITIALIZED -> {
2211                if (isInitiator()) {
2212                    return RtpEndUserState.RINGING;
2213                } else {
2214                    return RtpEndUserState.INCOMING_CALL;
2215                }
2216            }
2217            case PROCEED -> {
2218                if (isInitiator()) {
2219                    return RtpEndUserState.RINGING;
2220                } else {
2221                    return RtpEndUserState.ACCEPTING_CALL;
2222                }
2223            }
2224            case SESSION_INITIALIZED_PRE_APPROVED -> {
2225                if (isInitiator()) {
2226                    return RtpEndUserState.RINGING;
2227                } else {
2228                    return RtpEndUserState.CONNECTING;
2229                }
2230            }
2231            case SESSION_ACCEPTED -> {
2232                final ContentAddition ca = getPendingContentAddition();
2233                if (ca != null && ca.direction == ContentAddition.Direction.INCOMING) {
2234                    return RtpEndUserState.INCOMING_CONTENT_ADD;
2235                }
2236                return getPeerConnectionStateAsEndUserState();
2237            }
2238            case REJECTED, REJECTED_RACED, TERMINATED_DECLINED_OR_BUSY -> {
2239                if (isInitiator()) {
2240                    return RtpEndUserState.DECLINED_OR_BUSY;
2241                } else {
2242                    return RtpEndUserState.ENDED;
2243                }
2244            }
2245            case TERMINATED_SUCCESS, ACCEPTED, RETRACTED, TERMINATED_CANCEL_OR_TIMEOUT -> {
2246                return RtpEndUserState.ENDED;
2247            }
2248            case RETRACTED_RACED -> {
2249                if (isInitiator()) {
2250                    return RtpEndUserState.ENDED;
2251                } else {
2252                    return RtpEndUserState.RETRACTED;
2253                }
2254            }
2255            case TERMINATED_CONNECTIVITY_ERROR -> {
2256                return zeroDuration()
2257                        ? RtpEndUserState.CONNECTIVITY_ERROR
2258                        : RtpEndUserState.CONNECTIVITY_LOST_ERROR;
2259            }
2260            case TERMINATED_APPLICATION_FAILURE -> {
2261                return RtpEndUserState.APPLICATION_ERROR;
2262            }
2263            case TERMINATED_SECURITY_ERROR -> {
2264                return RtpEndUserState.SECURITY_ERROR;
2265            }
2266        }
2267        throw new IllegalStateException(
2268                String.format("%s has no equivalent EndUserState", this.state));
2269    }
2270
2271    private RtpEndUserState getPeerConnectionStateAsEndUserState() {
2272        final PeerConnection.PeerConnectionState state;
2273        try {
2274            state = webRTCWrapper.getState();
2275        } catch (final WebRTCWrapper.PeerConnectionNotInitialized e) {
2276            // We usually close the WebRTCWrapper *before* transitioning so we might still
2277            // be in SESSION_ACCEPTED even though the peerConnection has been torn down
2278            return RtpEndUserState.ENDING_CALL;
2279        }
2280        return switch (state) {
2281            case CONNECTED -> RtpEndUserState.CONNECTED;
2282            case NEW, CONNECTING -> RtpEndUserState.CONNECTING;
2283            case CLOSED -> RtpEndUserState.ENDING_CALL;
2284            default -> zeroDuration()
2285                    ? RtpEndUserState.CONNECTIVITY_ERROR
2286                    : RtpEndUserState.RECONNECTING;
2287        };
2288    }
2289
2290    public ContentAddition getPendingContentAddition() {
2291        final RtpContentMap in = this.incomingContentAdd;
2292        final RtpContentMap out = this.outgoingContentAdd;
2293        if (out != null) {
2294            return ContentAddition.of(ContentAddition.Direction.OUTGOING, out);
2295        } else if (in != null) {
2296            return ContentAddition.of(ContentAddition.Direction.INCOMING, in);
2297        } else {
2298            return null;
2299        }
2300    }
2301
2302    public Set<Media> getMedia() {
2303        final State current = getState();
2304        if (current == State.NULL) {
2305            if (isInitiator()) {
2306                return Preconditions.checkNotNull(
2307                        this.proposedMedia, "RTP connection has not been initialized properly");
2308            }
2309            throw new IllegalStateException("RTP connection has not been initialized yet");
2310        }
2311        if (Arrays.asList(State.PROPOSED, State.PROCEED).contains(current)) {
2312            return Preconditions.checkNotNull(
2313                    this.proposedMedia, "RTP connection has not been initialized properly");
2314        }
2315        final RtpContentMap localContentMap = getLocalContentMap();
2316        final RtpContentMap initiatorContentMap = initiatorRtpContentMap;
2317        if (localContentMap != null) {
2318            return localContentMap.getMedia();
2319        } else if (initiatorContentMap != null) {
2320            return initiatorContentMap.getMedia();
2321        } else if (isTerminated()) {
2322            return Collections.emptySet(); // we might fail before we ever got a chance to set media
2323        } else {
2324            return Preconditions.checkNotNull(
2325                    this.proposedMedia, "RTP connection has not been initialized properly");
2326        }
2327    }
2328
2329    public boolean isVerified() {
2330        final String fingerprint = this.omemoVerification.getFingerprint();
2331        if (fingerprint == null) {
2332            return false;
2333        }
2334        final FingerprintStatus status =
2335                id.account.getAxolotlService().getFingerprintTrust(fingerprint);
2336        return status != null && status.isVerified();
2337    }
2338
2339    public boolean addMedia(final Media media) {
2340        final Set<Media> currentMedia = getMedia();
2341        if (currentMedia.contains(media)) {
2342            throw new IllegalStateException(String.format("%s has already been proposed", media));
2343        }
2344        // TODO add state protection - can only add while ACCEPTED or so
2345        Log.d(Config.LOGTAG, "adding media: " + media);
2346        return webRTCWrapper.addTrack(media);
2347    }
2348
2349    public synchronized void acceptCall() {
2350        switch (this.state) {
2351            case PROPOSED -> {
2352                cancelRingingTimeout();
2353                acceptCallFromProposed();
2354            }
2355            case SESSION_INITIALIZED -> {
2356                cancelRingingTimeout();
2357                acceptCallFromSessionInitialized();
2358            }
2359            case ACCEPTED -> Log.w(
2360                    Config.LOGTAG,
2361                    id.account.getJid().asBareJid()
2362                            + ": the call has already been accepted  with another client. UI was just lagging behind");
2363            case PROCEED, SESSION_ACCEPTED -> Log.w(
2364                    Config.LOGTAG,
2365                    id.account.getJid().asBareJid()
2366                            + ": the call has already been accepted. user probably double tapped the UI");
2367            default -> throw new IllegalStateException("Can not accept call from " + this.state);
2368        }
2369    }
2370
2371    public void notifyPhoneCall() {
2372        Log.d(Config.LOGTAG, "a phone call has just been started. killing jingle rtp connections");
2373        if (Arrays.asList(State.PROPOSED, State.SESSION_INITIALIZED).contains(this.state)) {
2374            rejectCall();
2375        } else {
2376            endCall();
2377        }
2378    }
2379
2380    public synchronized void rejectCall() {
2381        if (isTerminated()) {
2382            Log.w(
2383                    Config.LOGTAG,
2384                    id.account.getJid().asBareJid()
2385                            + ": received rejectCall() when session has already been terminated. nothing to do");
2386            return;
2387        }
2388        switch (this.state) {
2389            case PROPOSED -> rejectCallFromProposed();
2390            case SESSION_INITIALIZED -> rejectCallFromSessionInitiate();
2391            default -> throw new IllegalStateException("Can not reject call from " + this.state);
2392        }
2393    }
2394
2395    public synchronized void endCall() {
2396        if (isTerminated()) {
2397            Log.w(
2398                    Config.LOGTAG,
2399                    id.account.getJid().asBareJid()
2400                            + ": received endCall() when session has already been terminated. nothing to do");
2401            return;
2402        }
2403        if (isInState(State.PROPOSED) && !isInitiator()) {
2404            rejectCallFromProposed();
2405            return;
2406        }
2407        if (isInState(State.PROCEED)) {
2408            if (isInitiator()) {
2409                retractFromProceed();
2410            } else {
2411                rejectCallFromProceed();
2412            }
2413            return;
2414        }
2415        if (isInitiator()
2416                && isInState(State.SESSION_INITIALIZED, State.SESSION_INITIALIZED_PRE_APPROVED)) {
2417            this.webRTCWrapper.close();
2418            sendSessionTerminate(Reason.CANCEL);
2419            return;
2420        }
2421        if (isInState(State.SESSION_INITIALIZED)) {
2422            rejectCallFromSessionInitiate();
2423            return;
2424        }
2425        if (isInState(State.SESSION_INITIALIZED_PRE_APPROVED, State.SESSION_ACCEPTED)) {
2426            this.webRTCWrapper.close();
2427            sendSessionTerminate(Reason.SUCCESS);
2428            return;
2429        }
2430        if (isInState(
2431                State.TERMINATED_APPLICATION_FAILURE,
2432                State.TERMINATED_CONNECTIVITY_ERROR,
2433                State.TERMINATED_DECLINED_OR_BUSY)) {
2434            Log.d(
2435                    Config.LOGTAG,
2436                    "ignoring request to end call because already in state " + this.state);
2437            return;
2438        }
2439        throw new IllegalStateException(
2440                "called 'endCall' while in state " + this.state + ". isInitiator=" + isInitiator());
2441    }
2442
2443    private void retractFromProceed() {
2444        Log.d(Config.LOGTAG, "retract from proceed");
2445        this.sendJingleMessage("retract");
2446        closeTransitionLogFinish(State.RETRACTED_RACED);
2447    }
2448
2449    private void closeTransitionLogFinish(final State state) {
2450        this.webRTCWrapper.close();
2451        transitionOrThrow(state);
2452        writeLogMessage(state);
2453        finish();
2454    }
2455
2456    private void setupWebRTC(
2457            final Set<Media> media,
2458            final List<PeerConnection.IceServer> iceServers,
2459            final boolean trickle)
2460            throws WebRTCWrapper.InitializationException {
2461        this.jingleConnectionManager.ensureConnectionIsRegistered(this);
2462        this.webRTCWrapper.setup(
2463                this.xmppConnectionService, AppRTCAudioManager.SpeakerPhonePreference.of(media));
2464        this.webRTCWrapper.initializePeerConnection(media, iceServers, trickle);
2465    }
2466
2467    private void acceptCallFromProposed() {
2468        transitionOrThrow(State.PROCEED);
2469        xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
2470        this.sendJingleMessage("accept", id.account.getJid().asBareJid());
2471        this.sendJingleMessage("proceed");
2472    }
2473
2474    private void rejectCallFromProposed() {
2475        transitionOrThrow(State.REJECTED);
2476        writeLogMessageMissed();
2477        xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
2478        this.sendJingleMessage("reject");
2479        finish();
2480    }
2481
2482    private void rejectCallFromProceed() {
2483        this.sendJingleMessage("reject");
2484        closeTransitionLogFinish(State.REJECTED_RACED);
2485    }
2486
2487    private void rejectCallFromSessionInitiate() {
2488        webRTCWrapper.close();
2489        sendSessionTerminate(Reason.DECLINE);
2490        xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
2491    }
2492
2493    private void sendJingleMessage(final String action) {
2494        sendJingleMessage(action, id.with);
2495    }
2496
2497    private void sendJingleMessage(final String action, final Jid to) {
2498        final MessagePacket messagePacket = new MessagePacket();
2499        messagePacket.setType(MessagePacket.TYPE_CHAT); // we want to carbon copy those
2500        messagePacket.setTo(to);
2501        final Element intent =
2502                messagePacket
2503                        .addChild(action, Namespace.JINGLE_MESSAGE)
2504                        .setAttribute("id", id.sessionId);
2505        if ("proceed".equals(action)) {
2506            messagePacket.setId(JINGLE_MESSAGE_PROCEED_ID_PREFIX + id.sessionId);
2507            if (isOmemoEnabled()) {
2508                final int deviceId = id.account.getAxolotlService().getOwnDeviceId();
2509                final Element device =
2510                        intent.addChild("device", Namespace.OMEMO_DTLS_SRTP_VERIFICATION);
2511                device.setAttribute("id", deviceId);
2512            }
2513        }
2514        messagePacket.addChild("store", "urn:xmpp:hints");
2515        xmppConnectionService.sendMessagePacket(id.account, messagePacket);
2516    }
2517
2518    private boolean isOmemoEnabled() {
2519        final Conversational conversational = message.getConversation();
2520        if (conversational instanceof Conversation) {
2521            return ((Conversation) conversational).getNextEncryption()
2522                    == Message.ENCRYPTION_AXOLOTL;
2523        }
2524        return false;
2525    }
2526
2527    private void acceptCallFromSessionInitialized() {
2528        xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
2529        sendSessionAccept();
2530    }
2531
2532    private synchronized boolean isInState(State... state) {
2533        return Arrays.asList(state).contains(this.state);
2534    }
2535
2536    private boolean transition(final State target) {
2537        return transition(target, null);
2538    }
2539
2540    private synchronized boolean transition(final State target, final Runnable runnable) {
2541        final Collection<State> validTransitions = VALID_TRANSITIONS.get(this.state);
2542        if (validTransitions != null && validTransitions.contains(target)) {
2543            this.state = target;
2544            if (runnable != null) {
2545                runnable.run();
2546            }
2547            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": transitioned into " + target);
2548            updateEndUserState();
2549            updateOngoingCallNotification();
2550            return true;
2551        } else {
2552            return false;
2553        }
2554    }
2555
2556    void transitionOrThrow(final State target) {
2557        if (!transition(target)) {
2558            throw new IllegalStateException(
2559                    String.format("Unable to transition from %s to %s", this.state, target));
2560        }
2561    }
2562
2563    @Override
2564    public void onIceCandidate(final IceCandidate iceCandidate) {
2565        final RtpContentMap rtpContentMap =
2566                isInitiator() ? this.initiatorRtpContentMap : this.responderRtpContentMap;
2567        final IceUdpTransportInfo.Credentials credentials;
2568        try {
2569            credentials = rtpContentMap.getCredentials(iceCandidate.sdpMid);
2570        } catch (final IllegalArgumentException e) {
2571            Log.d(Config.LOGTAG, "ignoring (not sending) candidate: " + iceCandidate, e);
2572            return;
2573        }
2574        final String uFrag = credentials.ufrag;
2575        final IceUdpTransportInfo.Candidate candidate =
2576                IceUdpTransportInfo.Candidate.fromSdpAttribute(iceCandidate.sdp, uFrag);
2577        if (candidate == null) {
2578            Log.d(Config.LOGTAG, "ignoring (not sending) candidate: " + iceCandidate);
2579            return;
2580        }
2581        Log.d(Config.LOGTAG, "sending candidate: " + iceCandidate);
2582        sendTransportInfo(iceCandidate.sdpMid, candidate);
2583    }
2584
2585    @Override
2586    public void onConnectionChange(final PeerConnection.PeerConnectionState newState) {
2587        Log.d(
2588                Config.LOGTAG,
2589                id.account.getJid().asBareJid() + ": PeerConnectionState changed to " + newState);
2590        this.stateHistory.add(newState);
2591        if (newState == PeerConnection.PeerConnectionState.CONNECTED) {
2592            this.sessionDuration.start();
2593            updateOngoingCallNotification();
2594        } else if (this.sessionDuration.isRunning()) {
2595            this.sessionDuration.stop();
2596            updateOngoingCallNotification();
2597        }
2598
2599        final boolean neverConnected =
2600                !this.stateHistory.contains(PeerConnection.PeerConnectionState.CONNECTED);
2601
2602        if (newState == PeerConnection.PeerConnectionState.FAILED) {
2603            if (neverConnected) {
2604                if (isTerminated()) {
2605                    Log.d(
2606                            Config.LOGTAG,
2607                            id.account.getJid().asBareJid()
2608                                    + ": not sending session-terminate after connectivity error because session is already in state "
2609                                    + this.state);
2610                    return;
2611                }
2612                webRTCWrapper.execute(this::closeWebRTCSessionAfterFailedConnection);
2613                return;
2614            } else {
2615                this.restartIce();
2616            }
2617        }
2618        updateEndUserState();
2619    }
2620
2621    private void restartIce() {
2622        this.stateHistory.clear();
2623        this.webRTCWrapper.restartIceAsync();
2624    }
2625
2626    @Override
2627    public void onRenegotiationNeeded() {
2628        this.webRTCWrapper.execute(this::renegotiate);
2629    }
2630
2631    private void renegotiate() {
2632        final SessionDescription sessionDescription;
2633        try {
2634            sessionDescription = setLocalSessionDescription();
2635        } catch (final Exception e) {
2636            final Throwable cause = Throwables.getRootCause(e);
2637            webRTCWrapper.close();
2638            if (isTerminated()) {
2639                Log.d(
2640                        Config.LOGTAG,
2641                        "failed to renegotiate. session was already terminated",
2642                        cause);
2643                return;
2644            }
2645            Log.d(Config.LOGTAG, "failed to renegotiate. sending session-terminate", cause);
2646            sendSessionTerminate(Reason.FAILED_APPLICATION, cause.getMessage());
2647            return;
2648        }
2649        final RtpContentMap rtpContentMap = RtpContentMap.of(sessionDescription, isInitiator());
2650        final RtpContentMap currentContentMap = getLocalContentMap();
2651        final boolean iceRestart = currentContentMap.iceRestart(rtpContentMap);
2652        final RtpContentMap.Diff diff = currentContentMap.diff(rtpContentMap);
2653
2654        Log.d(
2655                Config.LOGTAG,
2656                id.getAccount().getJid().asBareJid()
2657                        + ": renegotiate. iceRestart="
2658                        + iceRestart
2659                        + " content id diff="
2660                        + diff);
2661
2662        if (diff.hasModifications() && iceRestart) {
2663            webRTCWrapper.close();
2664            sendSessionTerminate(
2665                    Reason.FAILED_APPLICATION,
2666                    "WebRTC unexpectedly tried to modify content and transport at once");
2667            return;
2668        }
2669
2670        if (iceRestart) {
2671            initiateIceRestart(rtpContentMap);
2672            return;
2673        } else if (diff.isEmpty()) {
2674            Log.d(
2675                    Config.LOGTAG,
2676                    "renegotiation. nothing to do. SignalingState="
2677                            + this.webRTCWrapper.getSignalingState());
2678        }
2679
2680        if (diff.added.size() > 0) {
2681            modifyLocalContentMap(rtpContentMap);
2682            sendContentAdd(rtpContentMap, diff.added);
2683        }
2684    }
2685
2686    private void initiateIceRestart(final RtpContentMap rtpContentMap) {
2687        final RtpContentMap transportInfo = rtpContentMap.transportInfo();
2688        final JinglePacket jinglePacket =
2689                transportInfo.toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
2690        Log.d(Config.LOGTAG, "initiating ice restart: " + jinglePacket);
2691        jinglePacket.setTo(id.with);
2692        xmppConnectionService.sendIqPacket(
2693                id.account,
2694                jinglePacket,
2695                (account, response) -> {
2696                    if (response.getType() == IqPacket.TYPE.RESULT) {
2697                        Log.d(Config.LOGTAG, "received success to our ice restart");
2698                        setLocalContentMap(rtpContentMap);
2699                        webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
2700                        return;
2701                    }
2702                    if (response.getType() == IqPacket.TYPE.ERROR) {
2703                        if (isTieBreak(response)) {
2704                            Log.d(Config.LOGTAG, "received tie-break as result of ice restart");
2705                            return;
2706                        }
2707                        handleIqErrorResponse(response);
2708                    }
2709                    if (response.getType() == IqPacket.TYPE.TIMEOUT) {
2710                        handleIqTimeoutResponse(response);
2711                    }
2712                });
2713    }
2714
2715    private boolean isTieBreak(final IqPacket response) {
2716        final Element error = response.findChild("error");
2717        return error != null && error.hasChild("tie-break", Namespace.JINGLE_ERRORS);
2718    }
2719
2720    private void sendContentAdd(final RtpContentMap rtpContentMap, final Collection<String> added) {
2721        final RtpContentMap contentAdd = rtpContentMap.toContentModification(added);
2722        this.outgoingContentAdd = contentAdd;
2723        final ListenableFuture<RtpContentMap> outgoingContentMapFuture =
2724                prepareOutgoingContentMap(contentAdd);
2725        Futures.addCallback(
2726                outgoingContentMapFuture,
2727                new FutureCallback<>() {
2728                    @Override
2729                    public void onSuccess(final RtpContentMap outgoingContentMap) {
2730                        sendContentAdd(outgoingContentMap);
2731                        webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
2732                    }
2733
2734                    @Override
2735                    public void onFailure(@NonNull Throwable throwable) {
2736                        failureToPerformAction(JinglePacket.Action.CONTENT_ADD, throwable);
2737                    }
2738                },
2739                MoreExecutors.directExecutor());
2740    }
2741
2742    private void sendContentAdd(final RtpContentMap contentAdd) {
2743
2744        final JinglePacket jinglePacket =
2745                contentAdd.toJinglePacket(JinglePacket.Action.CONTENT_ADD, id.sessionId);
2746        jinglePacket.setTo(id.with);
2747        xmppConnectionService.sendIqPacket(
2748                id.account,
2749                jinglePacket,
2750                (connection, response) -> {
2751                    if (response.getType() == IqPacket.TYPE.RESULT) {
2752                        Log.d(
2753                                Config.LOGTAG,
2754                                id.getAccount().getJid().asBareJid()
2755                                        + ": received ACK to our content-add");
2756                        return;
2757                    }
2758                    if (response.getType() == IqPacket.TYPE.ERROR) {
2759                        if (isTieBreak(response)) {
2760                            this.outgoingContentAdd = null;
2761                            Log.d(Config.LOGTAG, "received tie-break as result of our content-add");
2762                            return;
2763                        }
2764                        handleIqErrorResponse(response);
2765                    }
2766                    if (response.getType() == IqPacket.TYPE.TIMEOUT) {
2767                        handleIqTimeoutResponse(response);
2768                    }
2769                });
2770    }
2771
2772    private void setLocalContentMap(final RtpContentMap rtpContentMap) {
2773        if (isInitiator()) {
2774            this.initiatorRtpContentMap = rtpContentMap;
2775        } else {
2776            this.responderRtpContentMap = rtpContentMap;
2777        }
2778    }
2779
2780    private void setRemoteContentMap(final RtpContentMap rtpContentMap) {
2781        if (isInitiator()) {
2782            this.responderRtpContentMap = rtpContentMap;
2783        } else {
2784            this.initiatorRtpContentMap = rtpContentMap;
2785        }
2786    }
2787
2788    // this method is to be used for content map modifications that modify media
2789    private void modifyLocalContentMap(final RtpContentMap rtpContentMap) {
2790        final RtpContentMap activeContents = rtpContentMap.activeContents();
2791        setLocalContentMap(activeContents);
2792        this.webRTCWrapper.switchSpeakerPhonePreference(
2793                AppRTCAudioManager.SpeakerPhonePreference.of(activeContents.getMedia()));
2794        updateEndUserState();
2795    }
2796
2797    private SessionDescription setLocalSessionDescription()
2798            throws ExecutionException, InterruptedException {
2799        final org.webrtc.SessionDescription sessionDescription =
2800                this.webRTCWrapper.setLocalDescription(false).get();
2801        return SessionDescription.parse(sessionDescription.description);
2802    }
2803
2804    private void closeWebRTCSessionAfterFailedConnection() {
2805        this.webRTCWrapper.close();
2806        synchronized (this) {
2807            if (isTerminated()) {
2808                Log.d(
2809                        Config.LOGTAG,
2810                        id.account.getJid().asBareJid()
2811                                + ": no need to send session-terminate after failed connection. Other party already did");
2812                return;
2813            }
2814            sendSessionTerminate(Reason.CONNECTIVITY_ERROR);
2815        }
2816    }
2817
2818    public boolean zeroDuration() {
2819        return this.sessionDuration.elapsed(TimeUnit.NANOSECONDS) <= 0;
2820    }
2821
2822    public long getCallDuration() {
2823        return this.sessionDuration.elapsed(TimeUnit.MILLISECONDS);
2824    }
2825
2826    public AppRTCAudioManager getAudioManager() {
2827        return webRTCWrapper.getAudioManager();
2828    }
2829
2830    public boolean isMicrophoneEnabled() {
2831        return webRTCWrapper.isMicrophoneEnabled();
2832    }
2833
2834    public boolean setMicrophoneEnabled(final boolean enabled) {
2835        return webRTCWrapper.setMicrophoneEnabled(enabled);
2836    }
2837
2838    public boolean isVideoEnabled() {
2839        return webRTCWrapper.isVideoEnabled();
2840    }
2841
2842    public void setVideoEnabled(final boolean enabled) {
2843        webRTCWrapper.setVideoEnabled(enabled);
2844    }
2845
2846    public boolean isCameraSwitchable() {
2847        return webRTCWrapper.isCameraSwitchable();
2848    }
2849
2850    public boolean isFrontCamera() {
2851        return webRTCWrapper.isFrontCamera();
2852    }
2853
2854    public ListenableFuture<Boolean> switchCamera() {
2855        return webRTCWrapper.switchCamera();
2856    }
2857
2858    @Override
2859    public void onAudioDeviceChanged(
2860            AppRTCAudioManager.AudioDevice selectedAudioDevice,
2861            Set<AppRTCAudioManager.AudioDevice> availableAudioDevices) {
2862        xmppConnectionService.notifyJingleRtpConnectionUpdate(
2863                selectedAudioDevice, availableAudioDevices);
2864    }
2865
2866    private void updateEndUserState() {
2867        final RtpEndUserState endUserState = getEndUserState();
2868        jingleConnectionManager.toneManager.transition(isInitiator(), endUserState, getMedia());
2869        xmppConnectionService.notifyJingleRtpConnectionUpdate(
2870                id.account, id.with, id.sessionId, endUserState);
2871    }
2872
2873    private void updateOngoingCallNotification() {
2874        final State state = this.state;
2875        if (STATES_SHOWING_ONGOING_CALL.contains(state)) {
2876            final boolean reconnecting;
2877            if (state == State.SESSION_ACCEPTED) {
2878                reconnecting =
2879                        getPeerConnectionStateAsEndUserState() == RtpEndUserState.RECONNECTING;
2880            } else {
2881                reconnecting = false;
2882            }
2883            xmppConnectionService.setOngoingCall(id, getMedia(), reconnecting);
2884        } else {
2885            xmppConnectionService.removeOngoingCall();
2886        }
2887    }
2888
2889    private void discoverIceServers(final OnIceServersDiscovered onIceServersDiscovered) {
2890        if (id.account.getXmppConnection().getFeatures().externalServiceDiscovery()) {
2891            final IqPacket request = new IqPacket(IqPacket.TYPE.GET);
2892            request.setTo(id.account.getDomain());
2893            request.addChild("services", Namespace.EXTERNAL_SERVICE_DISCOVERY);
2894            xmppConnectionService.sendIqPacket(
2895                    id.account,
2896                    request,
2897                    (account, response) -> {
2898                        ImmutableList.Builder<PeerConnection.IceServer> listBuilder =
2899                                new ImmutableList.Builder<>();
2900                        if (response.getType() == IqPacket.TYPE.RESULT) {
2901                            final Element services =
2902                                    response.findChild(
2903                                            "services", Namespace.EXTERNAL_SERVICE_DISCOVERY);
2904                            final List<Element> children =
2905                                    services == null
2906                                            ? Collections.emptyList()
2907                                            : services.getChildren();
2908                            for (final Element child : children) {
2909                                if ("service".equals(child.getName())) {
2910                                    final String type = child.getAttribute("type");
2911                                    final String host = child.getAttribute("host");
2912                                    final String sport = child.getAttribute("port");
2913                                    final Integer port =
2914                                            sport == null ? null : Ints.tryParse(sport);
2915                                    final String transport = child.getAttribute("transport");
2916                                    final String username = child.getAttribute("username");
2917                                    final String password = child.getAttribute("password");
2918                                    if (Strings.isNullOrEmpty(host) || port == null) {
2919                                        continue;
2920                                    }
2921                                    if (port < 0 || port > 65535) {
2922                                        continue;
2923                                    }
2924
2925                                    if (Arrays.asList("stun", "stuns", "turn", "turns")
2926                                                    .contains(type)
2927                                            && Arrays.asList("udp", "tcp").contains(transport)) {
2928                                        if (Arrays.asList("stuns", "turns").contains(type)
2929                                                && "udp".equals(transport)) {
2930                                            Log.d(
2931                                                    Config.LOGTAG,
2932                                                    id.account.getJid().asBareJid()
2933                                                            + ": skipping invalid combination of udp/tls in external services");
2934                                            continue;
2935                                        }
2936
2937                                        // STUN URLs do not support a query section since M110
2938                                        final String uri;
2939                                        if (Arrays.asList("stun", "stuns").contains(type)) {
2940                                            uri =
2941                                                    String.format(
2942                                                            "%s:%s:%s",
2943                                                            type, IP.wrapIPv6(host), port);
2944                                        } else {
2945                                            uri =
2946                                                    String.format(
2947                                                            "%s:%s:%s?transport=%s",
2948                                                            type,
2949                                                            IP.wrapIPv6(host),
2950                                                            port,
2951                                                            transport);
2952                                        }
2953
2954                                        final PeerConnection.IceServer.Builder iceServerBuilder =
2955                                                PeerConnection.IceServer.builder(uri);
2956                                        iceServerBuilder.setTlsCertPolicy(
2957                                                PeerConnection.TlsCertPolicy
2958                                                        .TLS_CERT_POLICY_INSECURE_NO_CHECK);
2959                                        if (username != null && password != null) {
2960                                            iceServerBuilder.setUsername(username);
2961                                            iceServerBuilder.setPassword(password);
2962                                        } else if (Arrays.asList("turn", "turns").contains(type)) {
2963                                            // The WebRTC spec requires throwing an
2964                                            // InvalidAccessError when username (from libwebrtc
2965                                            // source coder)
2966                                            // https://chromium.googlesource.com/external/webrtc/+/master/pc/ice_server_parsing.cc
2967                                            Log.d(
2968                                                    Config.LOGTAG,
2969                                                    id.account.getJid().asBareJid()
2970                                                            + ": skipping "
2971                                                            + type
2972                                                            + "/"
2973                                                            + transport
2974                                                            + " without username and password");
2975                                            continue;
2976                                        }
2977                                        final PeerConnection.IceServer iceServer =
2978                                                iceServerBuilder.createIceServer();
2979                                        Log.d(
2980                                                Config.LOGTAG,
2981                                                id.account.getJid().asBareJid()
2982                                                        + ": discovered ICE Server: "
2983                                                        + iceServer);
2984                                        listBuilder.add(iceServer);
2985                                    }
2986                                }
2987                            }
2988                        }
2989                        final List<PeerConnection.IceServer> iceServers = listBuilder.build();
2990                        if (iceServers.size() == 0) {
2991                            Log.w(
2992                                    Config.LOGTAG,
2993                                    id.account.getJid().asBareJid()
2994                                            + ": no ICE server found "
2995                                            + response);
2996                        }
2997                        onIceServersDiscovered.onIceServersDiscovered(iceServers);
2998                    });
2999        } else {
3000            Log.w(
3001                    Config.LOGTAG,
3002                    id.account.getJid().asBareJid() + ": has no external service discovery");
3003            onIceServersDiscovered.onIceServersDiscovered(Collections.emptyList());
3004        }
3005    }
3006
3007    private void finish() {
3008        if (isTerminated()) {
3009            this.cancelRingingTimeout();
3010            this.webRTCWrapper.verifyClosed();
3011            this.jingleConnectionManager.setTerminalSessionState(id, getEndUserState(), getMedia());
3012            this.jingleConnectionManager.finishConnectionOrThrow(this);
3013        } else {
3014            throw new IllegalStateException(
3015                    String.format("Unable to call finish from %s", this.state));
3016        }
3017    }
3018
3019    private void writeLogMessage(final State state) {
3020        final long duration = getCallDuration();
3021        if (state == State.TERMINATED_SUCCESS
3022                || (state == State.TERMINATED_CONNECTIVITY_ERROR && duration > 0)) {
3023            writeLogMessageSuccess(duration);
3024        } else {
3025            writeLogMessageMissed();
3026        }
3027    }
3028
3029    private void writeLogMessageSuccess(final long duration) {
3030        this.message.setBody(new RtpSessionStatus(true, duration).toString());
3031        this.writeMessage();
3032    }
3033
3034    private void writeLogMessageMissed() {
3035        this.message.setBody(new RtpSessionStatus(false, 0).toString());
3036        this.writeMessage();
3037    }
3038
3039    private void writeMessage() {
3040        final Conversational conversational = message.getConversation();
3041        if (conversational instanceof Conversation) {
3042            ((Conversation) conversational).add(this.message);
3043            xmppConnectionService.createMessageAsync(message);
3044            xmppConnectionService.updateConversationUi();
3045        } else {
3046            throw new IllegalStateException("Somehow the conversation in a message was a stub");
3047        }
3048    }
3049
3050    public State getState() {
3051        return this.state;
3052    }
3053
3054    boolean isTerminated() {
3055        return TERMINATED.contains(this.state);
3056    }
3057
3058    public Optional<VideoTrack> getLocalVideoTrack() {
3059        return webRTCWrapper.getLocalVideoTrack();
3060    }
3061
3062    public Optional<VideoTrack> getRemoteVideoTrack() {
3063        return webRTCWrapper.getRemoteVideoTrack();
3064    }
3065
3066    public EglBase.Context getEglBaseContext() {
3067        return webRTCWrapper.getEglBaseContext();
3068    }
3069
3070    void setProposedMedia(final Set<Media> media) {
3071        this.proposedMedia = media;
3072    }
3073
3074    public void fireStateUpdate() {
3075        final RtpEndUserState endUserState = getEndUserState();
3076        xmppConnectionService.notifyJingleRtpConnectionUpdate(
3077                id.account, id.with, id.sessionId, endUserState);
3078    }
3079
3080    public boolean isSwitchToVideoAvailable() {
3081        final boolean prerequisite =
3082                Media.audioOnly(getMedia())
3083                        && Arrays.asList(RtpEndUserState.CONNECTED, RtpEndUserState.RECONNECTING)
3084                                .contains(getEndUserState());
3085        return prerequisite && remoteHasVideoFeature();
3086    }
3087
3088    private boolean remoteHasVideoFeature() {
3089        return remoteHasFeature(Namespace.JINGLE_FEATURE_VIDEO);
3090    }
3091
3092    private boolean remoteHasSdpOfferAnswer() {
3093        return remoteHasFeature(Namespace.SDP_OFFER_ANSWER);
3094    }
3095
3096    private boolean remoteHasFeature(final String feature) {
3097        final Contact contact = id.getContact();
3098        final Presence presence =
3099                contact.getPresences().get(Strings.nullToEmpty(id.with.getResource()));
3100        final ServiceDiscoveryResult serviceDiscoveryResult =
3101                presence == null ? null : presence.getServiceDiscoveryResult();
3102        final List<String> features =
3103                serviceDiscoveryResult == null ? null : serviceDiscoveryResult.getFeatures();
3104        return features != null && features.contains(feature);
3105    }
3106
3107    private interface OnIceServersDiscovered {
3108        void onIceServersDiscovered(List<PeerConnection.IceServer> iceServers);
3109    }
3110}