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