JingleRtpConnection.java

   1package eu.siacs.conversations.xmpp.jingle;
   2
   3import android.os.SystemClock;
   4import android.util.Log;
   5
   6import com.google.common.base.Optional;
   7import com.google.common.base.Preconditions;
   8import com.google.common.base.Strings;
   9import com.google.common.base.Throwables;
  10import com.google.common.collect.Collections2;
  11import com.google.common.collect.ImmutableList;
  12import com.google.common.collect.ImmutableMap;
  13import com.google.common.collect.Sets;
  14import com.google.common.primitives.Ints;
  15import com.google.common.util.concurrent.ListenableFuture;
  16
  17import org.webrtc.EglBase;
  18import org.webrtc.IceCandidate;
  19import org.webrtc.PeerConnection;
  20import org.webrtc.VideoTrack;
  21
  22import java.util.ArrayDeque;
  23import java.util.Arrays;
  24import java.util.Collection;
  25import java.util.Collections;
  26import java.util.List;
  27import java.util.Map;
  28import java.util.Set;
  29import java.util.concurrent.ScheduledFuture;
  30import java.util.concurrent.TimeUnit;
  31
  32import eu.siacs.conversations.Config;
  33import eu.siacs.conversations.entities.Account;
  34import eu.siacs.conversations.entities.Conversation;
  35import eu.siacs.conversations.entities.Conversational;
  36import eu.siacs.conversations.entities.Message;
  37import eu.siacs.conversations.entities.RtpSessionStatus;
  38import eu.siacs.conversations.services.AppRTCAudioManager;
  39import eu.siacs.conversations.xml.Element;
  40import eu.siacs.conversations.xml.Namespace;
  41import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
  42import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
  43import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
  44import eu.siacs.conversations.xmpp.jingle.stanzas.Propose;
  45import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
  46import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
  47import eu.siacs.conversations.xmpp.stanzas.IqPacket;
  48import eu.siacs.conversations.xmpp.stanzas.MessagePacket;
  49import rocks.xmpp.addr.Jid;
  50
  51public class JingleRtpConnection extends AbstractJingleConnection implements WebRTCWrapper.EventCallback {
  52
  53    public static final List<State> STATES_SHOWING_ONGOING_CALL = Arrays.asList(
  54            State.PROCEED,
  55            State.SESSION_INITIALIZED_PRE_APPROVED,
  56            State.SESSION_ACCEPTED
  57    );
  58    private static final long BUSY_TIME_OUT = 30;
  59    private static final List<State> TERMINATED = Arrays.asList(
  60            State.TERMINATED_SUCCESS,
  61            State.TERMINATED_DECLINED_OR_BUSY,
  62            State.TERMINATED_CONNECTIVITY_ERROR,
  63            State.TERMINATED_CANCEL_OR_TIMEOUT,
  64            State.TERMINATED_APPLICATION_FAILURE
  65    );
  66
  67    private static final Map<State, Collection<State>> VALID_TRANSITIONS;
  68
  69    static {
  70        final ImmutableMap.Builder<State, Collection<State>> transitionBuilder = new ImmutableMap.Builder<>();
  71        transitionBuilder.put(State.NULL, ImmutableList.of(
  72                State.PROPOSED,
  73                State.SESSION_INITIALIZED,
  74                State.TERMINATED_APPLICATION_FAILURE
  75        ));
  76        transitionBuilder.put(State.PROPOSED, ImmutableList.of(
  77                State.ACCEPTED,
  78                State.PROCEED,
  79                State.REJECTED,
  80                State.RETRACTED,
  81                State.TERMINATED_APPLICATION_FAILURE,
  82                State.TERMINATED_CONNECTIVITY_ERROR //only used when the xmpp connection rebinds
  83        ));
  84        transitionBuilder.put(State.PROCEED, ImmutableList.of(
  85                State.SESSION_INITIALIZED_PRE_APPROVED,
  86                State.TERMINATED_SUCCESS,
  87                State.TERMINATED_APPLICATION_FAILURE,
  88                State.TERMINATED_CONNECTIVITY_ERROR //at this state used for error bounces of the proceed message
  89        ));
  90        transitionBuilder.put(State.SESSION_INITIALIZED, ImmutableList.of(
  91                State.SESSION_ACCEPTED,
  92                State.TERMINATED_SUCCESS,
  93                State.TERMINATED_DECLINED_OR_BUSY,
  94                State.TERMINATED_CONNECTIVITY_ERROR,  //at this state used for IQ errors and IQ timeouts
  95                State.TERMINATED_CANCEL_OR_TIMEOUT,
  96                State.TERMINATED_APPLICATION_FAILURE
  97        ));
  98        transitionBuilder.put(State.SESSION_INITIALIZED_PRE_APPROVED, ImmutableList.of(
  99                State.SESSION_ACCEPTED,
 100                State.TERMINATED_SUCCESS,
 101                State.TERMINATED_DECLINED_OR_BUSY,
 102                State.TERMINATED_CONNECTIVITY_ERROR,  //at this state used for IQ errors and IQ timeouts
 103                State.TERMINATED_CANCEL_OR_TIMEOUT,
 104                State.TERMINATED_APPLICATION_FAILURE
 105        ));
 106        transitionBuilder.put(State.SESSION_ACCEPTED, ImmutableList.of(
 107                State.TERMINATED_SUCCESS,
 108                State.TERMINATED_DECLINED_OR_BUSY,
 109                State.TERMINATED_CONNECTIVITY_ERROR,
 110                State.TERMINATED_CANCEL_OR_TIMEOUT,
 111                State.TERMINATED_APPLICATION_FAILURE
 112        ));
 113        VALID_TRANSITIONS = transitionBuilder.build();
 114    }
 115
 116    private final WebRTCWrapper webRTCWrapper = new WebRTCWrapper(this);
 117    private final ArrayDeque<IceCandidate> pendingIceCandidates = new ArrayDeque<>();
 118    private final Message message;
 119    private State state = State.NULL;
 120    private Set<Media> proposedMedia;
 121    private RtpContentMap initiatorRtpContentMap;
 122    private RtpContentMap responderRtpContentMap;
 123    private long rtpConnectionStarted = 0; //time of 'connected'
 124    private ScheduledFuture<?> ringingTimeoutFuture;
 125
 126    JingleRtpConnection(JingleConnectionManager jingleConnectionManager, Id id, Jid initiator) {
 127        super(jingleConnectionManager, id, initiator);
 128        final Conversation conversation = jingleConnectionManager.getXmppConnectionService().findOrCreateConversation(
 129                id.account,
 130                id.with.asBareJid(),
 131                false,
 132                false
 133        );
 134        this.message = new Message(
 135                conversation,
 136                isInitiator() ? Message.STATUS_SEND : Message.STATUS_RECEIVED,
 137                Message.TYPE_RTP_SESSION,
 138                id.sessionId
 139        );
 140    }
 141
 142    private static State reasonToState(Reason reason) {
 143        switch (reason) {
 144            case SUCCESS:
 145                return State.TERMINATED_SUCCESS;
 146            case DECLINE:
 147            case BUSY:
 148                return State.TERMINATED_DECLINED_OR_BUSY;
 149            case CANCEL:
 150            case TIMEOUT:
 151                return State.TERMINATED_CANCEL_OR_TIMEOUT;
 152            case FAILED_APPLICATION:
 153            case SECURITY_ERROR:
 154            case UNSUPPORTED_TRANSPORTS:
 155            case UNSUPPORTED_APPLICATIONS:
 156                return State.TERMINATED_APPLICATION_FAILURE;
 157            default:
 158                return State.TERMINATED_CONNECTIVITY_ERROR;
 159        }
 160    }
 161
 162    @Override
 163    synchronized void deliverPacket(final JinglePacket jinglePacket) {
 164        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": packet delivered to JingleRtpConnection");
 165        switch (jinglePacket.getAction()) {
 166            case SESSION_INITIATE:
 167                receiveSessionInitiate(jinglePacket);
 168                break;
 169            case TRANSPORT_INFO:
 170                receiveTransportInfo(jinglePacket);
 171                break;
 172            case SESSION_ACCEPT:
 173                receiveSessionAccept(jinglePacket);
 174                break;
 175            case SESSION_TERMINATE:
 176                receiveSessionTerminate(jinglePacket);
 177                break;
 178            default:
 179                respondOk(jinglePacket);
 180                Log.d(Config.LOGTAG, String.format("%s: received unhandled jingle action %s", id.account.getJid().asBareJid(), jinglePacket.getAction()));
 181                break;
 182        }
 183    }
 184
 185    @Override
 186    synchronized void notifyRebound() {
 187        if (isTerminated()) {
 188            return;
 189        }
 190        webRTCWrapper.close();
 191        if (!isInitiator() && isInState(State.PROPOSED, State.SESSION_INITIALIZED)) {
 192            xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
 193        }
 194        if (isInState(State.SESSION_INITIALIZED, State.SESSION_INITIALIZED_PRE_APPROVED, State.SESSION_ACCEPTED)) {
 195            //we might have already changed resources (full jid) at this point; so this might not even reach the other party
 196            sendSessionTerminate(Reason.CONNECTIVITY_ERROR);
 197        } else {
 198            transitionOrThrow(State.TERMINATED_CONNECTIVITY_ERROR);
 199            finish();
 200        }
 201    }
 202
 203    private void receiveSessionTerminate(final JinglePacket jinglePacket) {
 204        respondOk(jinglePacket);
 205        final JinglePacket.ReasonWrapper wrapper = jinglePacket.getReason();
 206        final State previous = this.state;
 207        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received session terminate reason=" + wrapper.reason + "(" + Strings.nullToEmpty(wrapper.text) + ") while in state " + previous);
 208        if (TERMINATED.contains(previous)) {
 209            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": ignoring session terminate because already in " + previous);
 210            return;
 211        }
 212        webRTCWrapper.close();
 213        final State target = reasonToState(wrapper.reason);
 214        transitionOrThrow(target);
 215        writeLogMessage(target);
 216        if (previous == State.PROPOSED || previous == State.SESSION_INITIALIZED) {
 217            xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
 218        }
 219        finish();
 220    }
 221
 222    private void receiveTransportInfo(final JinglePacket jinglePacket) {
 223        if (isInState(State.SESSION_INITIALIZED, State.SESSION_INITIALIZED_PRE_APPROVED, State.SESSION_ACCEPTED)) {
 224            respondOk(jinglePacket);
 225            final RtpContentMap contentMap;
 226            try {
 227                contentMap = RtpContentMap.of(jinglePacket);
 228            } catch (IllegalArgumentException | NullPointerException e) {
 229                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": improperly formatted contents; ignoring", e);
 230                return;
 231            }
 232            final RtpContentMap rtpContentMap = isInitiator() ? this.responderRtpContentMap : this.initiatorRtpContentMap;
 233            final Group originalGroup = rtpContentMap != null ? rtpContentMap.group : null;
 234            final List<String> identificationTags = originalGroup == null ? Collections.emptyList() : originalGroup.getIdentificationTags();
 235            if (identificationTags.size() == 0) {
 236                Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": no identification tags found in initial offer. we won't be able to calculate mLineIndices");
 237            }
 238            receiveCandidates(identificationTags, contentMap.contents.entrySet());
 239        } else {
 240            if (isTerminated()) {
 241                respondOk(jinglePacket);
 242                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": ignoring out-of-order transport info; we where already terminated");
 243            } else {
 244                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received transport info while in state=" + this.state);
 245                terminateWithOutOfOrder(jinglePacket);
 246            }
 247        }
 248    }
 249
 250    private void receiveCandidates(final List<String> identificationTags, final Set<Map.Entry<String, RtpContentMap.DescriptionTransport>> contents) {
 251        for (final Map.Entry<String, RtpContentMap.DescriptionTransport> content : contents) {
 252            final String ufrag = content.getValue().transport.getAttribute("ufrag");
 253            for (final IceUdpTransportInfo.Candidate candidate : content.getValue().transport.getCandidates()) {
 254                final String sdp;
 255                try {
 256                    sdp = candidate.toSdpAttribute(ufrag);
 257                } catch (IllegalArgumentException e) {
 258                    Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": ignoring invalid ICE candidate " + e.getMessage());
 259                    continue;
 260                }
 261                final String sdpMid = content.getKey();
 262                final int mLineIndex = identificationTags.indexOf(sdpMid);
 263                final IceCandidate iceCandidate = new IceCandidate(sdpMid, mLineIndex, sdp);
 264                if (isInState(State.SESSION_ACCEPTED)) {
 265                    Log.d(Config.LOGTAG, "received candidate: " + iceCandidate);
 266                    this.webRTCWrapper.addIceCandidate(iceCandidate);
 267                } else {
 268                    this.pendingIceCandidates.offer(iceCandidate);
 269                    Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": put ICE candidate on backlog");
 270                }
 271            }
 272        }
 273    }
 274
 275    private void receiveSessionInitiate(final JinglePacket jinglePacket) {
 276        if (isInitiator()) {
 277            Log.d(Config.LOGTAG, String.format("%s: received session-initiate even though we were initiating", id.account.getJid().asBareJid()));
 278            terminateWithOutOfOrder(jinglePacket);
 279            return;
 280        }
 281        final RtpContentMap contentMap;
 282        try {
 283            contentMap = RtpContentMap.of(jinglePacket);
 284            contentMap.requireContentDescriptions();
 285            contentMap.requireDTLSFingerprint();
 286        } catch (final RuntimeException e) {
 287            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": improperly formatted contents", Throwables.getRootCause(e));
 288            respondOk(jinglePacket);
 289            sendSessionTerminate(Reason.of(e), e.getMessage());
 290            return;
 291        }
 292        Log.d(Config.LOGTAG, "processing session-init with " + contentMap.contents.size() + " contents");
 293        final State target;
 294        if (this.state == State.PROCEED) {
 295            Preconditions.checkState(
 296                    proposedMedia != null && proposedMedia.size() > 0,
 297                    "proposed media must be set when processing pre-approved session-initiate"
 298            );
 299            if (!this.proposedMedia.equals(contentMap.getMedia())) {
 300                sendSessionTerminate(Reason.SECURITY_ERROR, String.format(
 301                        "Your session proposal (Jingle Message Initiation) included media %s but your session-initiate was %s",
 302                        this.proposedMedia,
 303                        contentMap.getMedia()
 304                ));
 305                return;
 306            }
 307            target = State.SESSION_INITIALIZED_PRE_APPROVED;
 308        } else {
 309            target = State.SESSION_INITIALIZED;
 310        }
 311        if (transition(target, () -> this.initiatorRtpContentMap = contentMap)) {
 312            respondOk(jinglePacket);
 313            final List<String> identificationTags = contentMap.group == null ? Collections.emptyList() : contentMap.group.getIdentificationTags();
 314            receiveCandidates(identificationTags, contentMap.contents.entrySet());
 315            if (target == State.SESSION_INITIALIZED_PRE_APPROVED) {
 316                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": automatically accepting session-initiate");
 317                sendSessionAccept();
 318            } else {
 319                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received not pre-approved session-initiate. start ringing");
 320                startRinging();
 321            }
 322        } else {
 323            Log.d(Config.LOGTAG, String.format("%s: received session-initiate while in state %s", id.account.getJid().asBareJid(), state));
 324            terminateWithOutOfOrder(jinglePacket);
 325        }
 326    }
 327
 328    private void receiveSessionAccept(final JinglePacket jinglePacket) {
 329        if (!isInitiator()) {
 330            Log.d(Config.LOGTAG, String.format("%s: received session-accept even though we were responding", id.account.getJid().asBareJid()));
 331            terminateWithOutOfOrder(jinglePacket);
 332            return;
 333        }
 334        final RtpContentMap contentMap;
 335        try {
 336            contentMap = RtpContentMap.of(jinglePacket);
 337            contentMap.requireContentDescriptions();
 338            contentMap.requireDTLSFingerprint();
 339        } catch (final RuntimeException e) {
 340            respondOk(jinglePacket);
 341            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": improperly formatted contents in session-accept", e);
 342            webRTCWrapper.close();
 343            sendSessionTerminate(Reason.of(e), e.getMessage());
 344            return;
 345        }
 346        final Set<Media> initiatorMedia = this.initiatorRtpContentMap.getMedia();
 347        if (!initiatorMedia.equals(contentMap.getMedia())) {
 348            sendSessionTerminate(Reason.SECURITY_ERROR, String.format(
 349                    "Your session-included included media %s but our session-initiate was %s",
 350                    this.proposedMedia,
 351                    contentMap.getMedia()
 352            ));
 353            return;
 354        }
 355        Log.d(Config.LOGTAG, "processing session-accept with " + contentMap.contents.size() + " contents");
 356        if (transition(State.SESSION_ACCEPTED)) {
 357            respondOk(jinglePacket);
 358            receiveSessionAccept(contentMap);
 359            final List<String> identificationTags = contentMap.group == null ? Collections.emptyList() : contentMap.group.getIdentificationTags();
 360            receiveCandidates(identificationTags, contentMap.contents.entrySet());
 361        } else {
 362            Log.d(Config.LOGTAG, String.format("%s: received session-accept while in state %s", id.account.getJid().asBareJid(), state));
 363            respondOk(jinglePacket);
 364        }
 365    }
 366
 367    private void receiveSessionAccept(final RtpContentMap contentMap) {
 368        this.responderRtpContentMap = contentMap;
 369        final SessionDescription sessionDescription;
 370        try {
 371            sessionDescription = SessionDescription.of(contentMap);
 372        } catch (final IllegalArgumentException | NullPointerException e) {
 373            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable convert offer from session-accept to SDP", e);
 374            webRTCWrapper.close();
 375            sendSessionTerminate(Reason.FAILED_APPLICATION, e.getMessage());
 376            return;
 377        }
 378        org.webrtc.SessionDescription answer = new org.webrtc.SessionDescription(
 379                org.webrtc.SessionDescription.Type.ANSWER,
 380                sessionDescription.toString()
 381        );
 382        try {
 383            this.webRTCWrapper.setRemoteDescription(answer).get();
 384        } catch (final Exception e) {
 385            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to set remote description after receiving session-accept", Throwables.getRootCause(e));
 386            webRTCWrapper.close();
 387            sendSessionTerminate(Reason.FAILED_APPLICATION);
 388        }
 389    }
 390
 391    private void sendSessionAccept() {
 392        final RtpContentMap rtpContentMap = this.initiatorRtpContentMap;
 393        if (rtpContentMap == null) {
 394            throw new IllegalStateException("initiator RTP Content Map has not been set");
 395        }
 396        final SessionDescription offer;
 397        try {
 398            offer = SessionDescription.of(rtpContentMap);
 399        } catch (final IllegalArgumentException | NullPointerException e) {
 400            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable convert offer from session-initiate to SDP", e);
 401            webRTCWrapper.close();
 402            sendSessionTerminate(Reason.FAILED_APPLICATION, e.getMessage());
 403            return;
 404        }
 405        sendSessionAccept(rtpContentMap.getMedia(), offer);
 406    }
 407
 408    private void sendSessionAccept(final Set<Media> media, final SessionDescription offer) {
 409        discoverIceServers(iceServers -> sendSessionAccept(media, offer, iceServers));
 410    }
 411
 412    private synchronized void sendSessionAccept(final Set<Media> media, final SessionDescription offer, final List<PeerConnection.IceServer> iceServers) {
 413        if (isTerminated()) {
 414            Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": ICE servers got discovered when session was already terminated. nothing to do.");
 415            return;
 416        }
 417        try {
 418            setupWebRTC(media, iceServers);
 419        } catch (final WebRTCWrapper.InitializationException e) {
 420            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to initialize WebRTC");
 421            webRTCWrapper.close();
 422            sendSessionTerminate(Reason.FAILED_APPLICATION);
 423            return;
 424        }
 425        final org.webrtc.SessionDescription sdp = new org.webrtc.SessionDescription(
 426                org.webrtc.SessionDescription.Type.OFFER,
 427                offer.toString()
 428        );
 429        try {
 430            this.webRTCWrapper.setRemoteDescription(sdp).get();
 431            addIceCandidatesFromBlackLog();
 432            org.webrtc.SessionDescription webRTCSessionDescription = this.webRTCWrapper.createAnswer().get();
 433            final SessionDescription sessionDescription = SessionDescription.parse(webRTCSessionDescription.description);
 434            final RtpContentMap respondingRtpContentMap = RtpContentMap.of(sessionDescription);
 435            sendSessionAccept(respondingRtpContentMap);
 436            this.webRTCWrapper.setLocalDescription(webRTCSessionDescription).get();
 437        } catch (final Exception e) {
 438            Log.d(Config.LOGTAG, "unable to send session accept", Throwables.getRootCause(e));
 439            webRTCWrapper.close();
 440            sendSessionTerminate(Reason.FAILED_APPLICATION);
 441        }
 442    }
 443
 444    private void addIceCandidatesFromBlackLog() {
 445        while (!this.pendingIceCandidates.isEmpty()) {
 446            final IceCandidate iceCandidate = this.pendingIceCandidates.poll();
 447            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": added ICE candidate from back log " + iceCandidate);
 448            this.webRTCWrapper.addIceCandidate(iceCandidate);
 449        }
 450    }
 451
 452    private void sendSessionAccept(final RtpContentMap rtpContentMap) {
 453        this.responderRtpContentMap = rtpContentMap;
 454        this.transitionOrThrow(State.SESSION_ACCEPTED);
 455        final JinglePacket sessionAccept = rtpContentMap.toJinglePacket(JinglePacket.Action.SESSION_ACCEPT, id.sessionId);
 456        Log.d(Config.LOGTAG, sessionAccept.toString());
 457        send(sessionAccept);
 458    }
 459
 460    synchronized void deliveryMessage(final Jid from, final Element message, final String serverMessageId, final long timestamp) {
 461        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": delivered message to JingleRtpConnection " + message);
 462        switch (message.getName()) {
 463            case "propose":
 464                receivePropose(from, Propose.upgrade(message), serverMessageId, timestamp);
 465                break;
 466            case "proceed":
 467                receiveProceed(from, serverMessageId, timestamp);
 468                break;
 469            case "retract":
 470                receiveRetract(from, serverMessageId, timestamp);
 471                break;
 472            case "reject":
 473                receiveReject(from, serverMessageId, timestamp);
 474                break;
 475            case "accept":
 476                receiveAccept(from, serverMessageId, timestamp);
 477                break;
 478            default:
 479                break;
 480        }
 481    }
 482
 483    void deliverFailedProceed() {
 484        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": receive message error for proceed message");
 485        if (transition(State.TERMINATED_CONNECTIVITY_ERROR)) {
 486            webRTCWrapper.close();
 487            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": transitioned into connectivity error");
 488            this.finish();
 489        }
 490    }
 491
 492    private void receiveAccept(final Jid from, final String serverMsgId, final long timestamp) {
 493        final boolean originatedFromMyself = from.asBareJid().equals(id.account.getJid().asBareJid());
 494        if (originatedFromMyself) {
 495            if (transition(State.ACCEPTED)) {
 496                if (serverMsgId != null) {
 497                    this.message.setServerMsgId(serverMsgId);
 498                }
 499                this.message.setTime(timestamp);
 500                this.message.setCarbon(true); //indicate that call was accepted on other device
 501                this.writeLogMessageSuccess(0);
 502                this.xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
 503                this.finish();
 504            } else {
 505                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to transition to accept because already in state=" + this.state);
 506            }
 507        } else {
 508            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": ignoring 'accept' from " + from);
 509        }
 510    }
 511
 512    private void receiveReject(Jid from, String serverMsgId, long timestamp) {
 513        final boolean originatedFromMyself = from.asBareJid().equals(id.account.getJid().asBareJid());
 514        //reject from another one of my clients
 515        if (originatedFromMyself) {
 516            if (transition(State.REJECTED)) {
 517                this.xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
 518                this.finish();
 519                if (serverMsgId != null) {
 520                    this.message.setServerMsgId(serverMsgId);
 521                }
 522                this.message.setTime(timestamp);
 523                this.message.setCarbon(true); //indicate that call was rejected on other device
 524                writeLogMessageMissed();
 525            } else {
 526                Log.d(Config.LOGTAG, "not able to transition into REJECTED because already in " + this.state);
 527            }
 528        } else {
 529            Log.d(Config.LOGTAG, id.account.getJid() + ": ignoring reject from " + from + " for session with " + id.with);
 530        }
 531    }
 532
 533    private void receivePropose(final Jid from, final Propose propose, final String serverMsgId, final long timestamp) {
 534        final boolean originatedFromMyself = from.asBareJid().equals(id.account.getJid().asBareJid());
 535        if (originatedFromMyself) {
 536            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": saw proposal from mysql. ignoring");
 537        } else if (transition(State.PROPOSED, () -> {
 538            final Collection<RtpDescription> descriptions = Collections2.transform(
 539                    Collections2.filter(propose.getDescriptions(), d -> d instanceof RtpDescription),
 540                    input -> (RtpDescription) input
 541            );
 542            final Collection<Media> media = Collections2.transform(descriptions, RtpDescription::getMedia);
 543            Preconditions.checkState(!media.contains(Media.UNKNOWN), "RTP descriptions contain unknown media");
 544            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received session proposal from " + from + " for " + media);
 545            this.proposedMedia = Sets.newHashSet(media);
 546        })) {
 547            if (serverMsgId != null) {
 548                this.message.setServerMsgId(serverMsgId);
 549            }
 550            this.message.setTime(timestamp);
 551            startRinging();
 552        } else {
 553            Log.d(Config.LOGTAG, id.account.getJid() + ": ignoring session proposal because already in " + state);
 554        }
 555    }
 556
 557    private void startRinging() {
 558        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received call from " + id.with + ". start ringing");
 559        ringingTimeoutFuture = jingleConnectionManager.schedule(this::ringingTimeout, BUSY_TIME_OUT, TimeUnit.SECONDS);
 560        xmppConnectionService.getNotificationService().showIncomingCallNotification(id, getMedia());
 561    }
 562
 563    private synchronized void ringingTimeout() {
 564        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": timeout reached for ringing");
 565        switch (this.state) {
 566            case PROPOSED:
 567                message.markUnread();
 568                rejectCallFromProposed();
 569                break;
 570            case SESSION_INITIALIZED:
 571                message.markUnread();
 572                rejectCallFromSessionInitiate();
 573                break;
 574        }
 575    }
 576
 577    private void cancelRingingTimeout() {
 578        final ScheduledFuture<?> future = this.ringingTimeoutFuture;
 579        if (future != null && !future.isCancelled()) {
 580            future.cancel(false);
 581        }
 582    }
 583
 584    private void receiveProceed(final Jid from, final String serverMsgId, final long timestamp) {
 585        final Set<Media> media = Preconditions.checkNotNull(this.proposedMedia, "Proposed media has to be set before handling proceed");
 586        Preconditions.checkState(media.size() > 0, "Proposed media should not be empty");
 587        if (from.equals(id.with)) {
 588            if (isInitiator()) {
 589                if (transition(State.PROCEED)) {
 590                    if (serverMsgId != null) {
 591                        this.message.setServerMsgId(serverMsgId);
 592                    }
 593                    this.message.setTime(timestamp);
 594                    this.sendSessionInitiate(media, State.SESSION_INITIALIZED_PRE_APPROVED);
 595                } else {
 596                    Log.d(Config.LOGTAG, String.format("%s: ignoring proceed because already in %s", id.account.getJid().asBareJid(), this.state));
 597                }
 598            } else {
 599                Log.d(Config.LOGTAG, String.format("%s: ignoring proceed because we were not initializing", id.account.getJid().asBareJid()));
 600            }
 601        } else if (from.asBareJid().equals(id.account.getJid().asBareJid())) {
 602            if (transition(State.ACCEPTED)) {
 603                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": moved session with " + id.with + " into state accepted after received carbon copied procced");
 604                this.xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
 605                this.finish();
 606            }
 607        } else {
 608            Log.d(Config.LOGTAG, String.format("%s: ignoring proceed from %s. was expected from %s", id.account.getJid().asBareJid(), from, id.with));
 609        }
 610    }
 611
 612    private void receiveRetract(final Jid from, final String serverMsgId, final long timestamp) {
 613        if (from.equals(id.with)) {
 614            if (transition(State.RETRACTED)) {
 615                xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
 616                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": session with " + id.with + " has been retracted (serverMsgId=" + serverMsgId + ")");
 617                if (serverMsgId != null) {
 618                    this.message.setServerMsgId(serverMsgId);
 619                }
 620                this.message.setTime(timestamp);
 621                this.message.markUnread();
 622                writeLogMessageMissed();
 623                finish();
 624            } else {
 625                Log.d(Config.LOGTAG, "ignoring retract because already in " + this.state);
 626            }
 627        } else {
 628            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received retract from " + from + ". expected retract from" + id.with + ". ignoring");
 629        }
 630    }
 631
 632    private void sendSessionInitiate(final Set<Media> media, final State targetState) {
 633        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": prepare session-initiate");
 634        discoverIceServers(iceServers -> sendSessionInitiate(media, targetState, iceServers));
 635    }
 636
 637    private synchronized void sendSessionInitiate(final Set<Media> media, final State targetState, final List<PeerConnection.IceServer> iceServers) {
 638        if (isTerminated()) {
 639            Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": ICE servers got discovered when session was already terminated. nothing to do.");
 640            return;
 641        }
 642        try {
 643            setupWebRTC(media, iceServers);
 644        } catch (WebRTCWrapper.InitializationException e) {
 645            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to initialize WebRTC");
 646            webRTCWrapper.close();
 647            //todo we haven’t actually initiated the session yet; so sending sessionTerminate makes no sense
 648            //todo either we don’t ring ever at all or maybe we should send a retract or something
 649            transitionOrThrow(State.TERMINATED_APPLICATION_FAILURE);
 650            return;
 651        }
 652        try {
 653            org.webrtc.SessionDescription webRTCSessionDescription = this.webRTCWrapper.createOffer().get();
 654            final SessionDescription sessionDescription = SessionDescription.parse(webRTCSessionDescription.description);
 655            final RtpContentMap rtpContentMap = RtpContentMap.of(sessionDescription);
 656            sendSessionInitiate(rtpContentMap, targetState);
 657            this.webRTCWrapper.setLocalDescription(webRTCSessionDescription).get();
 658        } catch (final Exception e) {
 659            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to sendSessionInitiate", Throwables.getRootCause(e));
 660            webRTCWrapper.close();
 661            if (isInState(targetState)) {
 662                sendSessionTerminate(Reason.FAILED_APPLICATION);
 663            } else {
 664                transitionOrThrow(State.TERMINATED_APPLICATION_FAILURE);
 665            }
 666        }
 667    }
 668
 669    private void sendSessionInitiate(RtpContentMap rtpContentMap, final State targetState) {
 670        this.initiatorRtpContentMap = rtpContentMap;
 671        this.transitionOrThrow(targetState);
 672        final JinglePacket sessionInitiate = rtpContentMap.toJinglePacket(JinglePacket.Action.SESSION_INITIATE, id.sessionId);
 673        send(sessionInitiate);
 674    }
 675
 676    private void sendSessionTerminate(final Reason reason) {
 677        sendSessionTerminate(reason, null);
 678    }
 679
 680    private void sendSessionTerminate(final Reason reason, final String text) {
 681        final State previous = this.state;
 682        final State target = reasonToState(reason);
 683        transitionOrThrow(target);
 684        if (previous != State.NULL) {
 685            writeLogMessage(target);
 686        }
 687        final JinglePacket jinglePacket = new JinglePacket(JinglePacket.Action.SESSION_TERMINATE, id.sessionId);
 688        jinglePacket.setReason(reason, text);
 689        Log.d(Config.LOGTAG, jinglePacket.toString());
 690        send(jinglePacket);
 691        finish();
 692    }
 693
 694    private void sendTransportInfo(final String contentName, IceUdpTransportInfo.Candidate candidate) {
 695        final RtpContentMap transportInfo;
 696        try {
 697            final RtpContentMap rtpContentMap = isInitiator() ? this.initiatorRtpContentMap : this.responderRtpContentMap;
 698            transportInfo = rtpContentMap.transportInfo(contentName, candidate);
 699        } catch (final Exception e) {
 700            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to prepare transport-info from candidate for content=" + contentName);
 701            return;
 702        }
 703        final JinglePacket jinglePacket = transportInfo.toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
 704        send(jinglePacket);
 705    }
 706
 707    private void send(final JinglePacket jinglePacket) {
 708        jinglePacket.setTo(id.with);
 709        xmppConnectionService.sendIqPacket(id.account, jinglePacket, this::handleIqResponse);
 710    }
 711
 712    private synchronized void handleIqResponse(final Account account, final IqPacket response) {
 713        if (response.getType() == IqPacket.TYPE.ERROR) {
 714            final String errorCondition = response.getErrorCondition();
 715            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received IQ-error from " + response.getFrom() + " in RTP session. " + errorCondition);
 716            if (isTerminated()) {
 717                Log.i(Config.LOGTAG, id.account.getJid().asBareJid() + ": ignoring error because session was already terminated");
 718                return;
 719            }
 720            this.webRTCWrapper.close();
 721            final State target;
 722            if (Arrays.asList(
 723                    "service-unavailable",
 724                    "recipient-unavailable",
 725                    "remote-server-not-found",
 726                    "remote-server-timeout"
 727            ).contains(errorCondition)) {
 728                target = State.TERMINATED_CONNECTIVITY_ERROR;
 729            } else {
 730                target = State.TERMINATED_APPLICATION_FAILURE;
 731            }
 732            transitionOrThrow(target);
 733            this.finish();
 734        } else if (response.getType() == IqPacket.TYPE.TIMEOUT) {
 735            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received IQ timeout in RTP session with " + id.with + ". terminating with connectivity error");
 736            if (isTerminated()) {
 737                Log.i(Config.LOGTAG, id.account.getJid().asBareJid() + ": ignoring error because session was already terminated");
 738                return;
 739            }
 740            this.webRTCWrapper.close();
 741            transitionOrThrow(State.TERMINATED_CONNECTIVITY_ERROR);
 742            this.finish();
 743        }
 744    }
 745
 746    private void terminateWithOutOfOrder(final JinglePacket jinglePacket) {
 747        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": terminating session with out-of-order");
 748        this.webRTCWrapper.close();
 749        transitionOrThrow(State.TERMINATED_APPLICATION_FAILURE);
 750        respondWithOutOfOrder(jinglePacket);
 751        this.finish();
 752    }
 753
 754    private void respondWithOutOfOrder(final JinglePacket jinglePacket) {
 755        jingleConnectionManager.respondWithJingleError(id.account, jinglePacket, "out-of-order", "unexpected-request", "wait");
 756    }
 757
 758    private void respondOk(final JinglePacket jinglePacket) {
 759        xmppConnectionService.sendIqPacket(id.account, jinglePacket.generateResponse(IqPacket.TYPE.RESULT), null);
 760    }
 761
 762    public RtpEndUserState getEndUserState() {
 763        switch (this.state) {
 764            case PROPOSED:
 765            case SESSION_INITIALIZED:
 766                if (isInitiator()) {
 767                    return RtpEndUserState.RINGING;
 768                } else {
 769                    return RtpEndUserState.INCOMING_CALL;
 770                }
 771            case PROCEED:
 772                if (isInitiator()) {
 773                    return RtpEndUserState.RINGING;
 774                } else {
 775                    return RtpEndUserState.ACCEPTING_CALL;
 776                }
 777            case SESSION_INITIALIZED_PRE_APPROVED:
 778                if (isInitiator()) {
 779                    return RtpEndUserState.RINGING;
 780                } else {
 781                    return RtpEndUserState.CONNECTING;
 782                }
 783            case SESSION_ACCEPTED:
 784                final PeerConnection.PeerConnectionState state = webRTCWrapper.getState();
 785                if (state == PeerConnection.PeerConnectionState.CONNECTED) {
 786                    return RtpEndUserState.CONNECTED;
 787                } else if (state == PeerConnection.PeerConnectionState.NEW || state == PeerConnection.PeerConnectionState.CONNECTING) {
 788                    return RtpEndUserState.CONNECTING;
 789                } else if (state == PeerConnection.PeerConnectionState.CLOSED) {
 790                    return RtpEndUserState.ENDING_CALL;
 791                } else {
 792                    return RtpEndUserState.CONNECTIVITY_ERROR;
 793                }
 794            case REJECTED:
 795            case TERMINATED_DECLINED_OR_BUSY:
 796                if (isInitiator()) {
 797                    return RtpEndUserState.DECLINED_OR_BUSY;
 798                } else {
 799                    return RtpEndUserState.ENDED;
 800                }
 801            case TERMINATED_SUCCESS:
 802            case ACCEPTED:
 803            case RETRACTED:
 804            case TERMINATED_CANCEL_OR_TIMEOUT:
 805                return RtpEndUserState.ENDED;
 806            case TERMINATED_CONNECTIVITY_ERROR:
 807                return RtpEndUserState.CONNECTIVITY_ERROR;
 808            case TERMINATED_APPLICATION_FAILURE:
 809                return RtpEndUserState.APPLICATION_ERROR;
 810        }
 811        throw new IllegalStateException(String.format("%s has no equivalent EndUserState", this.state));
 812    }
 813
 814    public Set<Media> getMedia() {
 815        final State current = getState();
 816        if (current == State.NULL) {
 817            throw new IllegalStateException("RTP connection has not been initialized yet");
 818        }
 819        if (Arrays.asList(State.PROPOSED, State.PROCEED).contains(current)) {
 820            return Preconditions.checkNotNull(this.proposedMedia, "RTP connection has not been initialized properly");
 821        }
 822        final RtpContentMap initiatorContentMap = initiatorRtpContentMap;
 823        if (initiatorContentMap != null) {
 824            return initiatorContentMap.getMedia();
 825        } else if (isTerminated()) {
 826            return Collections.emptySet(); //we might fail before we ever got a chance to set media
 827        } else {
 828            return Preconditions.checkNotNull(this.proposedMedia, "RTP connection has not been initialized properly");
 829        }
 830    }
 831
 832
 833    public synchronized void acceptCall() {
 834        switch (this.state) {
 835            case PROPOSED:
 836                cancelRingingTimeout();
 837                acceptCallFromProposed();
 838                break;
 839            case SESSION_INITIALIZED:
 840                cancelRingingTimeout();
 841                acceptCallFromSessionInitialized();
 842                break;
 843            case ACCEPTED:
 844                Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": the call has already been accepted  with another client. UI was just lagging behind");
 845                break;
 846            case PROCEED:
 847            case SESSION_ACCEPTED:
 848                Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": the call has already been accepted. user probably double tapped the UI");
 849                break;
 850            default:
 851                throw new IllegalStateException("Can not accept call from " + this.state);
 852        }
 853    }
 854
 855    public synchronized void rejectCall() {
 856        switch (this.state) {
 857            case PROPOSED:
 858                rejectCallFromProposed();
 859                break;
 860            case SESSION_INITIALIZED:
 861                rejectCallFromSessionInitiate();
 862                break;
 863            default:
 864                throw new IllegalStateException("Can not reject call from " + this.state);
 865        }
 866    }
 867
 868    public synchronized void endCall() {
 869        if (isTerminated()) {
 870            Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": received endCall() when session has already been terminated. nothing to do");
 871            return;
 872        }
 873        if (isInState(State.PROPOSED) && !isInitiator()) {
 874            rejectCallFromProposed();
 875            return;
 876        }
 877        if (isInState(State.PROCEED)) {
 878            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": ending call while in state PROCEED just means ending the connection");
 879            this.jingleConnectionManager.endSession(id, State.TERMINATED_SUCCESS);
 880            this.webRTCWrapper.close();
 881            this.finish();
 882            transitionOrThrow(State.TERMINATED_SUCCESS); //arguably this wasn't success; but not a real failure either
 883            return;
 884        }
 885        if (isInitiator() && isInState(State.SESSION_INITIALIZED, State.SESSION_INITIALIZED_PRE_APPROVED)) {
 886            this.webRTCWrapper.close();
 887            sendSessionTerminate(Reason.CANCEL);
 888            return;
 889        }
 890        if (isInState(State.SESSION_INITIALIZED)) {
 891            rejectCallFromSessionInitiate();
 892            return;
 893        }
 894        if (isInState(State.SESSION_INITIALIZED_PRE_APPROVED, State.SESSION_ACCEPTED)) {
 895            this.webRTCWrapper.close();
 896            sendSessionTerminate(Reason.SUCCESS);
 897            return;
 898        }
 899        if (isInState(State.TERMINATED_APPLICATION_FAILURE, State.TERMINATED_CONNECTIVITY_ERROR, State.TERMINATED_DECLINED_OR_BUSY)) {
 900            Log.d(Config.LOGTAG, "ignoring request to end call because already in state " + this.state);
 901            return;
 902        }
 903        throw new IllegalStateException("called 'endCall' while in state " + this.state + ". isInitiator=" + isInitiator());
 904    }
 905
 906    private void setupWebRTC(final Set<Media> media, final List<PeerConnection.IceServer> iceServers) throws WebRTCWrapper.InitializationException {
 907        this.jingleConnectionManager.ensureConnectionIsRegistered(this);
 908        final AppRTCAudioManager.SpeakerPhonePreference speakerPhonePreference;
 909        if (media.contains(Media.VIDEO)) {
 910            speakerPhonePreference = AppRTCAudioManager.SpeakerPhonePreference.SPEAKER;
 911        } else {
 912            speakerPhonePreference = AppRTCAudioManager.SpeakerPhonePreference.EARPIECE;
 913        }
 914        this.webRTCWrapper.setup(this.xmppConnectionService, speakerPhonePreference);
 915        this.webRTCWrapper.initializePeerConnection(media, iceServers);
 916    }
 917
 918    private void acceptCallFromProposed() {
 919        transitionOrThrow(State.PROCEED);
 920        xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
 921        this.sendJingleMessage("accept", id.account.getJid().asBareJid());
 922        this.sendJingleMessage("proceed");
 923    }
 924
 925    private void rejectCallFromProposed() {
 926        transitionOrThrow(State.REJECTED);
 927        writeLogMessageMissed();
 928        xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
 929        this.sendJingleMessage("reject");
 930        finish();
 931    }
 932
 933    private void rejectCallFromSessionInitiate() {
 934        webRTCWrapper.close();
 935        sendSessionTerminate(Reason.DECLINE);
 936        xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
 937    }
 938
 939    private void sendJingleMessage(final String action) {
 940        sendJingleMessage(action, id.with);
 941    }
 942
 943    private void sendJingleMessage(final String action, final Jid to) {
 944        final MessagePacket messagePacket = new MessagePacket();
 945        if ("proceed".equals(action)) {
 946            messagePacket.setId(JINGLE_MESSAGE_PROCEED_ID_PREFIX + id.sessionId);
 947        }
 948        messagePacket.setType(MessagePacket.TYPE_CHAT); //we want to carbon copy those
 949        messagePacket.setTo(to);
 950        messagePacket.addChild(action, Namespace.JINGLE_MESSAGE).setAttribute("id", id.sessionId);
 951        messagePacket.addChild("store", "urn:xmpp:hints");
 952        xmppConnectionService.sendMessagePacket(id.account, messagePacket);
 953    }
 954
 955    private void acceptCallFromSessionInitialized() {
 956        xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
 957        sendSessionAccept();
 958    }
 959
 960    private synchronized boolean isInState(State... state) {
 961        return Arrays.asList(state).contains(this.state);
 962    }
 963
 964    private boolean transition(final State target) {
 965        return transition(target, null);
 966    }
 967
 968    private synchronized boolean transition(final State target, final Runnable runnable) {
 969        final Collection<State> validTransitions = VALID_TRANSITIONS.get(this.state);
 970        if (validTransitions != null && validTransitions.contains(target)) {
 971            this.state = target;
 972            if (runnable != null) {
 973                runnable.run();
 974            }
 975            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": transitioned into " + target);
 976            updateEndUserState();
 977            updateOngoingCallNotification();
 978            return true;
 979        } else {
 980            return false;
 981        }
 982    }
 983
 984    void transitionOrThrow(final State target) {
 985        if (!transition(target)) {
 986            throw new IllegalStateException(String.format("Unable to transition from %s to %s", this.state, target));
 987        }
 988    }
 989
 990    @Override
 991    public void onIceCandidate(final IceCandidate iceCandidate) {
 992        final IceUdpTransportInfo.Candidate candidate = IceUdpTransportInfo.Candidate.fromSdpAttribute(iceCandidate.sdp);
 993        Log.d(Config.LOGTAG, "sending candidate: " + iceCandidate.toString());
 994        sendTransportInfo(iceCandidate.sdpMid, candidate);
 995    }
 996
 997    @Override
 998    public void onConnectionChange(final PeerConnection.PeerConnectionState newState) {
 999        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": PeerConnectionState changed to " + newState);
1000        if (newState == PeerConnection.PeerConnectionState.CONNECTED && this.rtpConnectionStarted == 0) {
1001            this.rtpConnectionStarted = SystemClock.elapsedRealtime();
1002        }
1003        //TODO 'DISCONNECTED' might be an opportunity to renew the offer and send a transport-replace
1004        //TODO exact syntax is yet to be determined but transport-replace sounds like the most reasonable
1005        //as there is no content-replace
1006        if (Arrays.asList(PeerConnection.PeerConnectionState.FAILED, PeerConnection.PeerConnectionState.DISCONNECTED).contains(newState)) {
1007            if (isTerminated()) {
1008                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": not sending session-terminate after connectivity error because session is already in state " + this.state);
1009                return;
1010            }
1011            new Thread(this::closeWebRTCSessionAfterFailedConnection).start();
1012        } else {
1013            updateEndUserState();
1014        }
1015    }
1016
1017    private void closeWebRTCSessionAfterFailedConnection() {
1018        this.webRTCWrapper.close();
1019        synchronized (this) {
1020            if (isTerminated()) {
1021                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": no need to send session-terminate after failed connection. Other party already did");
1022                return;
1023            }
1024            sendSessionTerminate(Reason.CONNECTIVITY_ERROR);
1025        }
1026    }
1027
1028    public AppRTCAudioManager getAudioManager() {
1029        return webRTCWrapper.getAudioManager();
1030    }
1031
1032    public boolean isMicrophoneEnabled() {
1033        return webRTCWrapper.isMicrophoneEnabled();
1034    }
1035
1036    public void setMicrophoneEnabled(final boolean enabled) {
1037        webRTCWrapper.setMicrophoneEnabled(enabled);
1038    }
1039
1040    public boolean isVideoEnabled() {
1041        return webRTCWrapper.isVideoEnabled();
1042    }
1043
1044
1045    public boolean isCameraSwitchable() {
1046        return webRTCWrapper.isCameraSwitchable();
1047    }
1048
1049    public boolean isFrontCamera() {
1050        return webRTCWrapper.isFrontCamera();
1051    }
1052
1053    public ListenableFuture<Boolean> switchCamera() {
1054        return webRTCWrapper.switchCamera();
1055    }
1056
1057    public void setVideoEnabled(final boolean enabled) {
1058        webRTCWrapper.setVideoEnabled(enabled);
1059    }
1060
1061    @Override
1062    public void onAudioDeviceChanged(AppRTCAudioManager.AudioDevice selectedAudioDevice, Set<AppRTCAudioManager.AudioDevice> availableAudioDevices) {
1063        xmppConnectionService.notifyJingleRtpConnectionUpdate(selectedAudioDevice, availableAudioDevices);
1064    }
1065
1066    private void updateEndUserState() {
1067        final RtpEndUserState endUserState = getEndUserState();
1068        jingleConnectionManager.toneManager.transition(isInitiator(), endUserState, getMedia());
1069        xmppConnectionService.notifyJingleRtpConnectionUpdate(id.account, id.with, id.sessionId, endUserState);
1070    }
1071
1072    private void updateOngoingCallNotification() {
1073        if (STATES_SHOWING_ONGOING_CALL.contains(this.state)) {
1074            xmppConnectionService.setOngoingCall(id, getMedia());
1075        } else {
1076            xmppConnectionService.removeOngoingCall();
1077        }
1078    }
1079
1080    private void discoverIceServers(final OnIceServersDiscovered onIceServersDiscovered) {
1081        if (id.account.getXmppConnection().getFeatures().externalServiceDiscovery()) {
1082            final IqPacket request = new IqPacket(IqPacket.TYPE.GET);
1083            request.setTo(Jid.of(id.account.getJid().getDomain()));
1084            request.addChild("services", Namespace.EXTERNAL_SERVICE_DISCOVERY);
1085            xmppConnectionService.sendIqPacket(id.account, request, (account, response) -> {
1086                ImmutableList.Builder<PeerConnection.IceServer> listBuilder = new ImmutableList.Builder<>();
1087                if (response.getType() == IqPacket.TYPE.RESULT) {
1088                    final Element services = response.findChild("services", Namespace.EXTERNAL_SERVICE_DISCOVERY);
1089                    final List<Element> children = services == null ? Collections.emptyList() : services.getChildren();
1090                    for (final Element child : children) {
1091                        if ("service".equals(child.getName())) {
1092                            final String type = child.getAttribute("type");
1093                            final String host = child.getAttribute("host");
1094                            final String sport = child.getAttribute("port");
1095                            final Integer port = sport == null ? null : Ints.tryParse(sport);
1096                            final String transport = child.getAttribute("transport");
1097                            final String username = child.getAttribute("username");
1098                            final String password = child.getAttribute("password");
1099                            if (Strings.isNullOrEmpty(host) || port == null) {
1100                                continue;
1101                            }
1102                            if (port < 0 || port > 65535) {
1103                                continue;
1104                            }
1105                            if (Arrays.asList("stun", "stuns", "turn", "turns").contains(type) && Arrays.asList("udp", "tcp").contains(transport)) {
1106                                if (Arrays.asList("stuns", "turns").contains(type) && "udp".equals(transport)) {
1107                                    Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": skipping invalid combination of udp/tls in external services");
1108                                    continue;
1109                                }
1110                                //TODO wrap ipv6 addresses
1111                                final PeerConnection.IceServer.Builder iceServerBuilder = PeerConnection.IceServer
1112                                        .builder(String.format("%s:%s:%s?transport=%s", type, host, port, transport));
1113                                iceServerBuilder.setTlsCertPolicy(PeerConnection.TlsCertPolicy.TLS_CERT_POLICY_INSECURE_NO_CHECK);
1114                                if (username != null && password != null) {
1115                                    iceServerBuilder.setUsername(username);
1116                                    iceServerBuilder.setPassword(password);
1117                                } else if (Arrays.asList("turn", "turns").contains(type)) {
1118                                    //The WebRTC spec requires throwing an InvalidAccessError when username (from libwebrtc source coder)
1119                                    //https://chromium.googlesource.com/external/webrtc/+/master/pc/ice_server_parsing.cc
1120                                    Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": skipping " + type + "/" + transport + " without username and password");
1121                                    continue;
1122                                }
1123                                final PeerConnection.IceServer iceServer = iceServerBuilder.createIceServer();
1124                                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": discovered ICE Server: " + iceServer);
1125                                listBuilder.add(iceServer);
1126                            }
1127                        }
1128                    }
1129                }
1130                List<PeerConnection.IceServer> iceServers = listBuilder.build();
1131                if (iceServers.size() == 0) {
1132                    Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": no ICE server found " + response);
1133                }
1134                onIceServersDiscovered.onIceServersDiscovered(iceServers);
1135            });
1136        } else {
1137            Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": has no external service discovery");
1138            onIceServersDiscovered.onIceServersDiscovered(Collections.emptyList());
1139        }
1140    }
1141
1142    private void finish() {
1143        this.cancelRingingTimeout();
1144        this.webRTCWrapper.verifyClosed();
1145        this.jingleConnectionManager.finishConnection(this);
1146    }
1147
1148    private void writeLogMessage(final State state) {
1149        final long started = this.rtpConnectionStarted;
1150        long duration = started <= 0 ? 0 : SystemClock.elapsedRealtime() - started;
1151        if (state == State.TERMINATED_SUCCESS || (state == State.TERMINATED_CONNECTIVITY_ERROR && duration > 0)) {
1152            writeLogMessageSuccess(duration);
1153        } else {
1154            writeLogMessageMissed();
1155        }
1156    }
1157
1158    private void writeLogMessageSuccess(final long duration) {
1159        this.message.setBody(new RtpSessionStatus(true, duration).toString());
1160        this.writeMessage();
1161    }
1162
1163    private void writeLogMessageMissed() {
1164        this.message.setBody(new RtpSessionStatus(false, 0).toString());
1165        this.writeMessage();
1166    }
1167
1168    private void writeMessage() {
1169        final Conversational conversational = message.getConversation();
1170        if (conversational instanceof Conversation) {
1171            ((Conversation) conversational).add(this.message);
1172            xmppConnectionService.databaseBackend.createMessage(message);
1173            xmppConnectionService.updateConversationUi();
1174        } else {
1175            throw new IllegalStateException("Somehow the conversation in a message was a stub");
1176        }
1177    }
1178
1179    public State getState() {
1180        return this.state;
1181    }
1182
1183    public boolean isTerminated() {
1184        return TERMINATED.contains(this.state);
1185    }
1186
1187    public Optional<VideoTrack> getLocalVideoTrack() {
1188        return webRTCWrapper.getLocalVideoTrack();
1189    }
1190
1191    public Optional<VideoTrack> getRemoteVideoTrack() {
1192        return webRTCWrapper.getRemoteVideoTrack();
1193    }
1194
1195
1196    public EglBase.Context getEglBaseContext() {
1197        return webRTCWrapper.getEglBaseContext();
1198    }
1199
1200    void setProposedMedia(final Set<Media> media) {
1201        this.proposedMedia = media;
1202    }
1203
1204    private interface OnIceServersDiscovered {
1205        void onIceServersDiscovered(List<PeerConnection.IceServer> iceServers);
1206    }
1207}