JingleRtpConnection.java

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