JingleRtpConnection.java

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