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