JingleRtpConnection.java

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