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