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