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