JingleRtpConnection.java

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