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