JingleRtpConnection.java

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