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