JingleRtpConnection.java

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