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