JingleRtpConnection.java

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