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