JingleRtpConnection.java

  1package eu.siacs.conversations.xmpp.jingle;
  2
  3import android.util.Log;
  4
  5import com.google.common.base.Strings;
  6import com.google.common.collect.ImmutableList;
  7import com.google.common.collect.ImmutableMap;
  8import com.google.common.primitives.Ints;
  9
 10import org.webrtc.IceCandidate;
 11import org.webrtc.PeerConnection;
 12
 13import java.util.ArrayDeque;
 14import java.util.Arrays;
 15import java.util.Collection;
 16import java.util.Collections;
 17import java.util.List;
 18import java.util.Map;
 19
 20import eu.siacs.conversations.Config;
 21import eu.siacs.conversations.xml.Element;
 22import eu.siacs.conversations.xml.Namespace;
 23import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
 24import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
 25import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
 26import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
 27import eu.siacs.conversations.xmpp.stanzas.IqPacket;
 28import eu.siacs.conversations.xmpp.stanzas.MessagePacket;
 29import rocks.xmpp.addr.Jid;
 30
 31public class JingleRtpConnection extends AbstractJingleConnection implements WebRTCWrapper.EventCallback {
 32
 33    private static final Map<State, Collection<State>> VALID_TRANSITIONS;
 34
 35    static {
 36        final ImmutableMap.Builder<State, Collection<State>> transitionBuilder = new ImmutableMap.Builder<>();
 37        transitionBuilder.put(State.NULL, ImmutableList.of(
 38                State.PROPOSED,
 39                State.SESSION_INITIALIZED,
 40                State.TERMINATED_APPLICATION_FAILURE
 41        ));
 42        transitionBuilder.put(State.PROPOSED, ImmutableList.of(
 43                State.ACCEPTED,
 44                State.PROCEED,
 45                State.REJECTED,
 46                State.RETRACTED,
 47                State.TERMINATED_APPLICATION_FAILURE
 48        ));
 49        transitionBuilder.put(State.PROCEED, ImmutableList.of(
 50                State.SESSION_INITIALIZED_PRE_APPROVED,
 51                State.TERMINATED_SUCCESS,
 52                State.TERMINATED_APPLICATION_FAILURE
 53        ));
 54        transitionBuilder.put(State.SESSION_INITIALIZED, ImmutableList.of(
 55                State.SESSION_ACCEPTED,
 56                State.TERMINATED_CANCEL_OR_TIMEOUT,
 57                State.TERMINATED_DECLINED_OR_BUSY,
 58                State.TERMINATED_APPLICATION_FAILURE
 59        ));
 60        transitionBuilder.put(State.SESSION_INITIALIZED_PRE_APPROVED, ImmutableList.of(
 61                State.SESSION_ACCEPTED,
 62                State.TERMINATED_CANCEL_OR_TIMEOUT,
 63                State.TERMINATED_DECLINED_OR_BUSY,
 64                State.TERMINATED_APPLICATION_FAILURE
 65        ));
 66        transitionBuilder.put(State.SESSION_ACCEPTED, ImmutableList.of(
 67                State.TERMINATED_SUCCESS,
 68                State.TERMINATED_CONNECTIVITY_ERROR,
 69                State.TERMINATED_APPLICATION_FAILURE
 70        ));
 71        VALID_TRANSITIONS = transitionBuilder.build();
 72    }
 73
 74    private final WebRTCWrapper webRTCWrapper = new WebRTCWrapper(this);
 75    private final ArrayDeque<IceCandidate> pendingIceCandidates = new ArrayDeque<>();
 76    private State state = State.NULL;
 77    private RtpContentMap initiatorRtpContentMap;
 78    private RtpContentMap responderRtpContentMap;
 79
 80
 81    public JingleRtpConnection(JingleConnectionManager jingleConnectionManager, Id id, Jid initiator) {
 82        super(jingleConnectionManager, id, initiator);
 83    }
 84
 85    private static State reasonToState(Reason reason) {
 86        switch (reason) {
 87            case SUCCESS:
 88                return State.TERMINATED_SUCCESS;
 89            case DECLINE:
 90            case BUSY:
 91                return State.TERMINATED_DECLINED_OR_BUSY;
 92            case CANCEL:
 93            case TIMEOUT:
 94                return State.TERMINATED_CANCEL_OR_TIMEOUT;
 95            case FAILED_APPLICATION:
 96                return State.TERMINATED_APPLICATION_FAILURE;
 97            default:
 98                return State.TERMINATED_CONNECTIVITY_ERROR;
 99        }
100    }
101
102    @Override
103    void deliverPacket(final JinglePacket jinglePacket) {
104        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": packet delivered to JingleRtpConnection");
105        switch (jinglePacket.getAction()) {
106            case SESSION_INITIATE:
107                receiveSessionInitiate(jinglePacket);
108                break;
109            case TRANSPORT_INFO:
110                receiveTransportInfo(jinglePacket);
111                break;
112            case SESSION_ACCEPT:
113                receiveSessionAccept(jinglePacket);
114                break;
115            case SESSION_TERMINATE:
116                receiveSessionTerminate(jinglePacket);
117                break;
118            default:
119                respondOk(jinglePacket);
120                Log.d(Config.LOGTAG, String.format("%s: received unhandled jingle action %s", id.account.getJid().asBareJid(), jinglePacket.getAction()));
121                break;
122        }
123    }
124
125    private void receiveSessionTerminate(final JinglePacket jinglePacket) {
126        respondOk(jinglePacket);
127        final Reason reason = jinglePacket.getReason();
128        final State previous = this.state;
129        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received session terminate reason=" + reason + " while in state " + previous);
130        webRTCWrapper.close();
131        transitionOrThrow(reasonToState(reason));
132        if (previous == State.PROPOSED || previous == State.SESSION_INITIALIZED) {
133            xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
134        }
135        jingleConnectionManager.finishConnection(this);
136    }
137
138    private void receiveTransportInfo(final JinglePacket jinglePacket) {
139        if (isInState(State.SESSION_INITIALIZED, State.SESSION_INITIALIZED_PRE_APPROVED, State.SESSION_ACCEPTED)) {
140            respondOk(jinglePacket);
141            final RtpContentMap contentMap;
142            try {
143                contentMap = RtpContentMap.of(jinglePacket);
144            } catch (IllegalArgumentException | NullPointerException e) {
145                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": improperly formatted contents; ignoring", e);
146                return;
147            }
148            final RtpContentMap rtpContentMap = isInitiator() ? this.responderRtpContentMap : this.initiatorRtpContentMap;
149            final Group originalGroup = rtpContentMap != null ? rtpContentMap.group : null;
150            final List<String> identificationTags = originalGroup == null ? Collections.emptyList() : originalGroup.getIdentificationTags();
151            if (identificationTags.size() == 0) {
152                Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": no identification tags found in initial offer. we won't be able to calculate mLineIndices");
153            }
154            for (final Map.Entry<String, RtpContentMap.DescriptionTransport> content : contentMap.contents.entrySet()) {
155                final String ufrag = content.getValue().transport.getAttribute("ufrag");
156                for (final IceUdpTransportInfo.Candidate candidate : content.getValue().transport.getCandidates()) {
157                    final String sdp = candidate.toSdpAttribute(ufrag);
158                    final String sdpMid = content.getKey();
159                    final int mLineIndex = identificationTags.indexOf(sdpMid);
160                    final IceCandidate iceCandidate = new IceCandidate(sdpMid, mLineIndex, sdp);
161                    if (isInState(State.SESSION_ACCEPTED)) {
162                        Log.d(Config.LOGTAG, "received candidate: " + iceCandidate);
163                        this.webRTCWrapper.addIceCandidate(iceCandidate);
164                    } else {
165                        this.pendingIceCandidates.offer(iceCandidate);
166                        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": put ICE candidate on backlog");
167                    }
168                }
169            }
170        } else {
171            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received transport info while in state=" + this.state);
172            respondWithOutOfOrder(jinglePacket);
173        }
174    }
175
176    private void receiveSessionInitiate(final JinglePacket jinglePacket) {
177        if (isInitiator()) {
178            Log.d(Config.LOGTAG, String.format("%s: received session-initiate even though we were initiating", id.account.getJid().asBareJid()));
179            respondWithOutOfOrder(jinglePacket);
180            return;
181        }
182        final RtpContentMap contentMap;
183        try {
184            contentMap = RtpContentMap.of(jinglePacket);
185            contentMap.requireContentDescriptions();
186            //TODO requireTransportWithDtls();
187        } catch (IllegalArgumentException | IllegalStateException | NullPointerException e) {
188            respondOk(jinglePacket);
189            sendSessionTerminate(Reason.FAILED_APPLICATION);
190            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": improperly formatted contents", e);
191            return;
192        }
193        Log.d(Config.LOGTAG, "processing session-init with " + contentMap.contents.size() + " contents");
194        final State target;
195        if (this.state == State.PROCEED) {
196            target = State.SESSION_INITIALIZED_PRE_APPROVED;
197        } else {
198            target = State.SESSION_INITIALIZED;
199        }
200        if (transition(target)) {
201            respondOk(jinglePacket);
202            this.initiatorRtpContentMap = contentMap;
203            if (target == State.SESSION_INITIALIZED_PRE_APPROVED) {
204                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": automatically accepting session-initiate");
205                sendSessionAccept();
206            } else {
207                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received not pre-approved session-initiate. start ringing");
208                startRinging();
209            }
210        } else {
211            Log.d(Config.LOGTAG, String.format("%s: received session-initiate while in state %s", id.account.getJid().asBareJid(), state));
212            respondWithOutOfOrder(jinglePacket);
213        }
214    }
215
216    private void receiveSessionAccept(final JinglePacket jinglePacket) {
217        if (!isInitiator()) {
218            Log.d(Config.LOGTAG, String.format("%s: received session-accept even though we were responding", id.account.getJid().asBareJid()));
219            respondWithOutOfOrder(jinglePacket);
220            return;
221        }
222        final RtpContentMap contentMap;
223        try {
224            contentMap = RtpContentMap.of(jinglePacket);
225            contentMap.requireContentDescriptions();
226            //TODO requireTransportWithDtls();
227        } catch (IllegalArgumentException | IllegalStateException | NullPointerException e) {
228            respondOk(jinglePacket);
229            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": improperly formatted contents in session-accept", e);
230            webRTCWrapper.close();
231            sendSessionTerminate(Reason.FAILED_APPLICATION);
232            return;
233        }
234        Log.d(Config.LOGTAG, "processing session-accept with " + contentMap.contents.size() + " contents");
235        if (transition(State.SESSION_ACCEPTED)) {
236            respondOk(jinglePacket);
237            receiveSessionAccept(contentMap);
238        } else {
239            Log.d(Config.LOGTAG, String.format("%s: received session-accept while in state %s", id.account.getJid().asBareJid(), state));
240            respondOk(jinglePacket);
241        }
242    }
243
244    private void receiveSessionAccept(final RtpContentMap contentMap) {
245        this.responderRtpContentMap = contentMap;
246        final SessionDescription sessionDescription;
247        try {
248            sessionDescription = SessionDescription.of(contentMap);
249        } catch (IllegalArgumentException e) {
250            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable convert offer from session-accept to SDP", e);
251            webRTCWrapper.close();
252            sendSessionTerminate(Reason.FAILED_APPLICATION);
253            return;
254        }
255        org.webrtc.SessionDescription answer = new org.webrtc.SessionDescription(
256                org.webrtc.SessionDescription.Type.ANSWER,
257                sessionDescription.toString()
258        );
259        try {
260            this.webRTCWrapper.setRemoteDescription(answer).get();
261        } catch (Exception e) {
262            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to set remote description after receiving session-accept", e);
263            webRTCWrapper.close();
264            sendSessionTerminate(Reason.FAILED_APPLICATION);
265        }
266    }
267
268    private void sendSessionAccept() {
269        final RtpContentMap rtpContentMap = this.initiatorRtpContentMap;
270        if (rtpContentMap == null) {
271            throw new IllegalStateException("initiator RTP Content Map has not been set");
272        }
273        final SessionDescription offer;
274        try {
275            offer = SessionDescription.of(rtpContentMap);
276        } catch (final IllegalArgumentException e) {
277            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable convert offer from session-initiate to SDP", e);
278            webRTCWrapper.close();
279            sendSessionTerminate(Reason.FAILED_APPLICATION);
280            ;
281            return;
282        }
283        sendSessionAccept(offer);
284    }
285
286    private void sendSessionAccept(SessionDescription offer) {
287        discoverIceServers(iceServers -> {
288            try {
289                setupWebRTC(iceServers);
290            } catch (WebRTCWrapper.InitializationException e) {
291                sendSessionTerminate(Reason.FAILED_APPLICATION);
292                return;
293            }
294            final org.webrtc.SessionDescription sdp = new org.webrtc.SessionDescription(
295                    org.webrtc.SessionDescription.Type.OFFER,
296                    offer.toString()
297            );
298            try {
299                this.webRTCWrapper.setRemoteDescription(sdp).get();
300                addIceCandidatesFromBlackLog();
301                org.webrtc.SessionDescription webRTCSessionDescription = this.webRTCWrapper.createAnswer().get();
302                final SessionDescription sessionDescription = SessionDescription.parse(webRTCSessionDescription.description);
303                final RtpContentMap respondingRtpContentMap = RtpContentMap.of(sessionDescription);
304                sendSessionAccept(respondingRtpContentMap);
305                this.webRTCWrapper.setLocalDescription(webRTCSessionDescription);
306            } catch (Exception e) {
307                Log.d(Config.LOGTAG, "unable to send session accept", e);
308
309            }
310        });
311    }
312
313    private void addIceCandidatesFromBlackLog() {
314        while (!this.pendingIceCandidates.isEmpty()) {
315            final IceCandidate iceCandidate = this.pendingIceCandidates.poll();
316            this.webRTCWrapper.addIceCandidate(iceCandidate);
317            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": added ICE candidate from back log " + iceCandidate);
318        }
319    }
320
321    private void sendSessionAccept(final RtpContentMap rtpContentMap) {
322        this.responderRtpContentMap = rtpContentMap;
323        this.transitionOrThrow(State.SESSION_ACCEPTED);
324        final JinglePacket sessionAccept = rtpContentMap.toJinglePacket(JinglePacket.Action.SESSION_ACCEPT, id.sessionId);
325        Log.d(Config.LOGTAG, sessionAccept.toString());
326        send(sessionAccept);
327    }
328
329    void deliveryMessage(final Jid from, final Element message) {
330        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": delivered message to JingleRtpConnection " + message);
331        switch (message.getName()) {
332            case "propose":
333                receivePropose(from, message);
334                break;
335            case "proceed":
336                receiveProceed(from, message);
337                break;
338            case "retract":
339                receiveRetract(from, message);
340                break;
341            case "reject":
342                receiveReject(from, message);
343                break;
344            case "accept":
345                receiveAccept(from, message);
346                break;
347            default:
348                break;
349        }
350    }
351
352    private void receiveAccept(Jid from, Element message) {
353        final boolean originatedFromMyself = from.asBareJid().equals(id.account.getJid().asBareJid());
354        if (originatedFromMyself) {
355            if (transition(State.ACCEPTED)) {
356                this.xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
357                this.jingleConnectionManager.finishConnection(this);
358            } else {
359                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to transition to accept because already in state=" + this.state);
360            }
361        } else {
362            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": ignoring 'accept' from " + from);
363        }
364    }
365
366    private void receiveReject(Jid from, Element message) {
367        final boolean originatedFromMyself = from.asBareJid().equals(id.account.getJid().asBareJid());
368        //reject from another one of my clients
369        if (originatedFromMyself) {
370            if (transition(State.REJECTED)) {
371                this.xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
372                this.jingleConnectionManager.finishConnection(this);
373            } else {
374                Log.d(Config.LOGTAG, "not able to transition into REJECTED because already in " + this.state);
375            }
376        } else {
377            Log.d(Config.LOGTAG, id.account.getJid() + ": ignoring reject from " + from + " for session with " + id.with);
378        }
379    }
380
381    private void receivePropose(final Jid from, final Element propose) {
382        final boolean originatedFromMyself = from.asBareJid().equals(id.account.getJid().asBareJid());
383        //TODO we can use initiator logic here
384        if (originatedFromMyself) {
385            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": saw proposal from mysql. ignoring");
386        } else if (transition(State.PROPOSED)) {
387            startRinging();
388        } else {
389            Log.d(Config.LOGTAG, id.account.getJid() + ": ignoring session proposal because already in " + state);
390        }
391    }
392
393    private void startRinging() {
394        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received call from " + id.with + ". start ringing");
395        xmppConnectionService.getNotificationService().showIncomingCallNotification(id);
396    }
397
398    private void receiveProceed(final Jid from, final Element proceed) {
399        if (from.equals(id.with)) {
400            if (isInitiator()) {
401                if (transition(State.PROCEED)) {
402                    this.sendSessionInitiate(State.SESSION_INITIALIZED_PRE_APPROVED);
403                } else {
404                    Log.d(Config.LOGTAG, String.format("%s: ignoring proceed because already in %s", id.account.getJid().asBareJid(), this.state));
405                }
406            } else {
407                Log.d(Config.LOGTAG, String.format("%s: ignoring proceed because we were not initializing", id.account.getJid().asBareJid()));
408            }
409        } else if (from.asBareJid().equals(id.account.getJid().asBareJid())) {
410            if (transition(State.ACCEPTED)) {
411                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": moved session with " + id.with + " into state accepted after received carbon copied procced");
412                this.xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
413                this.jingleConnectionManager.finishConnection(this);
414            }
415        } else {
416            Log.d(Config.LOGTAG, String.format("%s: ignoring proceed from %s. was expected from %s", id.account.getJid().asBareJid(), from, id.with));
417        }
418    }
419
420    private void receiveRetract(final Jid from, final Element retract) {
421        if (from.equals(id.with)) {
422            if (transition(State.RETRACTED)) {
423                xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
424                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": session with " + id.with + " has been retracted");
425                //TODO create missed call notification/message
426                jingleConnectionManager.finishConnection(this);
427            } else {
428                Log.d(Config.LOGTAG, "ignoring retract because already in " + this.state);
429            }
430        } else {
431            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received retract from " + from + ". expected retract from" + id.with + ". ignoring");
432        }
433    }
434
435    private void sendSessionInitiate(final State targetState) {
436        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": prepare session-initiate");
437        discoverIceServers(iceServers -> {
438            try {
439                setupWebRTC(iceServers);
440            } catch (WebRTCWrapper.InitializationException e) {
441                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to initialize webrtc");
442                transitionOrThrow(State.TERMINATED_APPLICATION_FAILURE);
443                return;
444            }
445            try {
446                org.webrtc.SessionDescription webRTCSessionDescription = this.webRTCWrapper.createOffer().get();
447                final SessionDescription sessionDescription = SessionDescription.parse(webRTCSessionDescription.description);
448                Log.d(Config.LOGTAG, "description: " + webRTCSessionDescription.description);
449                final RtpContentMap rtpContentMap = RtpContentMap.of(sessionDescription);
450                sendSessionInitiate(rtpContentMap, targetState);
451                this.webRTCWrapper.setLocalDescription(webRTCSessionDescription).get();
452            } catch (final Exception e) {
453                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to sendSessionInitiate", e);
454                webRTCWrapper.close();
455                if (isInState(targetState)) {
456                    sendSessionTerminate(Reason.FAILED_APPLICATION);
457                } else {
458                    transitionOrThrow(State.TERMINATED_APPLICATION_FAILURE);
459                }
460            }
461        });
462    }
463
464    private void sendSessionInitiate(RtpContentMap rtpContentMap, final State targetState) {
465        this.initiatorRtpContentMap = rtpContentMap;
466        this.transitionOrThrow(targetState);
467        final JinglePacket sessionInitiate = rtpContentMap.toJinglePacket(JinglePacket.Action.SESSION_INITIATE, id.sessionId);
468        Log.d(Config.LOGTAG, sessionInitiate.toString());
469        send(sessionInitiate);
470    }
471
472    private void sendSessionTerminate(final Reason reason) {
473        final State target = reasonToState(reason);
474        transitionOrThrow(target);
475        final JinglePacket jinglePacket = new JinglePacket(JinglePacket.Action.SESSION_TERMINATE, id.sessionId);
476        jinglePacket.setReason(reason);
477        send(jinglePacket);
478        Log.d(Config.LOGTAG, jinglePacket.toString());
479        jingleConnectionManager.finishConnection(this);
480    }
481
482    private void sendTransportInfo(final String contentName, IceUdpTransportInfo.Candidate candidate) {
483        final RtpContentMap transportInfo;
484        try {
485            final RtpContentMap rtpContentMap = isInitiator() ? this.initiatorRtpContentMap : this.responderRtpContentMap;
486            transportInfo = rtpContentMap.transportInfo(contentName, candidate);
487        } catch (Exception e) {
488            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to prepare transport-info from candidate for content=" + contentName);
489            return;
490        }
491        final JinglePacket jinglePacket = transportInfo.toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
492        Log.d(Config.LOGTAG, jinglePacket.toString());
493        send(jinglePacket);
494    }
495
496    private void send(final JinglePacket jinglePacket) {
497        jinglePacket.setTo(id.with);
498        xmppConnectionService.sendIqPacket(id.account, jinglePacket, (account, response) -> {
499            if (response.getType() == IqPacket.TYPE.ERROR) {
500                final String errorCondition = response.getErrorCondition();
501                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received IQ-error from " + response.getFrom() + " in RTP session. " + errorCondition);
502                this.webRTCWrapper.close();
503                final State target;
504                if (Arrays.asList(
505                        "service-unavailable",
506                        "recipient-unavailable",
507                        "remote-server-not-found",
508                        "remote-server-timeout"
509                ).contains(errorCondition)) {
510                    target = State.TERMINATED_CONNECTIVITY_ERROR;
511                } else {
512                    target = State.TERMINATED_APPLICATION_FAILURE;
513                }
514                if (transition(target)) {
515                    Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": terminated session with " + id.with);
516                } else {
517                    Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": not transitioning because already at state=" + this.state);
518                }
519
520            } else if (response.getType() == IqPacket.TYPE.TIMEOUT) {
521                this.webRTCWrapper.close();
522                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received IQ timeout in RTP session with " + id.with + ". terminating with connectivity error");
523                transition(State.TERMINATED_CONNECTIVITY_ERROR);
524                this.jingleConnectionManager.finishConnection(this);
525            }
526        });
527    }
528
529    private void respondWithOutOfOrder(final JinglePacket jinglePacket) {
530        jingleConnectionManager.respondWithJingleError(id.account, jinglePacket, "out-of-order", "unexpected-request", "wait");
531    }
532
533    private void respondOk(final JinglePacket jinglePacket) {
534        xmppConnectionService.sendIqPacket(id.account, jinglePacket.generateResponse(IqPacket.TYPE.RESULT), null);
535    }
536
537    public RtpEndUserState getEndUserState() {
538        switch (this.state) {
539            case PROPOSED:
540            case SESSION_INITIALIZED:
541                if (isInitiator()) {
542                    return RtpEndUserState.RINGING;
543                } else {
544                    return RtpEndUserState.INCOMING_CALL;
545                }
546            case PROCEED:
547                if (isInitiator()) {
548                    return RtpEndUserState.RINGING;
549                } else {
550                    return RtpEndUserState.ACCEPTING_CALL;
551                }
552            case SESSION_INITIALIZED_PRE_APPROVED:
553                if (isInitiator()) {
554                    return RtpEndUserState.RINGING;
555                } else {
556                    return RtpEndUserState.CONNECTING;
557                }
558            case SESSION_ACCEPTED:
559                final PeerConnection.PeerConnectionState state = webRTCWrapper.getState();
560                if (state == PeerConnection.PeerConnectionState.CONNECTED) {
561                    return RtpEndUserState.CONNECTED;
562                } else if (state == PeerConnection.PeerConnectionState.NEW || state == PeerConnection.PeerConnectionState.CONNECTING) {
563                    return RtpEndUserState.CONNECTING;
564                } else if (state == PeerConnection.PeerConnectionState.CLOSED) {
565                    return RtpEndUserState.ENDING_CALL;
566                } else if (state == PeerConnection.PeerConnectionState.FAILED) {
567                    return RtpEndUserState.CONNECTIVITY_ERROR;
568                } else {
569                    return RtpEndUserState.ENDING_CALL;
570                }
571            case REJECTED:
572            case TERMINATED_DECLINED_OR_BUSY:
573                if (isInitiator()) {
574                    return RtpEndUserState.DECLINED_OR_BUSY;
575                } else {
576                    return RtpEndUserState.ENDED;
577                }
578            case TERMINATED_SUCCESS:
579            case ACCEPTED:
580            case RETRACTED:
581            case TERMINATED_CANCEL_OR_TIMEOUT:
582                return RtpEndUserState.ENDED;
583            case TERMINATED_CONNECTIVITY_ERROR:
584                return RtpEndUserState.CONNECTIVITY_ERROR;
585            case TERMINATED_APPLICATION_FAILURE:
586                return RtpEndUserState.APPLICATION_ERROR;
587        }
588        throw new IllegalStateException(String.format("%s has no equivalent EndUserState", this.state));
589    }
590
591
592    public void acceptCall() {
593        switch (this.state) {
594            case PROPOSED:
595                acceptCallFromProposed();
596                break;
597            case SESSION_INITIALIZED:
598                acceptCallFromSessionInitialized();
599                break;
600            default:
601                throw new IllegalStateException("Can not accept call from " + this.state);
602        }
603    }
604
605    public void rejectCall() {
606        switch (this.state) {
607            case PROPOSED:
608                rejectCallFromProposed();
609                break;
610            case SESSION_INITIALIZED:
611                rejectCallFromSessionInitiate();
612                break;
613            default:
614                throw new IllegalStateException("Can not reject call from " + this.state);
615        }
616    }
617
618    public void endCall() {
619        if (isInState(State.PROCEED)) {
620            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": ending call while in state PROCEED just means ending the connection");
621            webRTCWrapper.close();
622            jingleConnectionManager.finishConnection(this);
623            transitionOrThrow(State.TERMINATED_SUCCESS); //arguably this wasn't success; but not a real failure either
624            return;
625        }
626        if (isInitiator() && isInState(State.SESSION_INITIALIZED, State.SESSION_INITIALIZED_PRE_APPROVED)) {
627            webRTCWrapper.close();
628            sendSessionTerminate(Reason.CANCEL);
629            return;
630        }
631        if (isInState(State.SESSION_INITIALIZED, State.SESSION_INITIALIZED_PRE_APPROVED, State.SESSION_ACCEPTED)) {
632            webRTCWrapper.close();
633            sendSessionTerminate(Reason.SUCCESS);
634            return;
635        }
636        throw new IllegalStateException("called 'endCall' while in state " + this.state);
637    }
638
639    private void setupWebRTC(final List<PeerConnection.IceServer> iceServers) throws WebRTCWrapper.InitializationException {
640        this.webRTCWrapper.setup(this.xmppConnectionService);
641        this.webRTCWrapper.initializePeerConnection(iceServers);
642    }
643
644    private void acceptCallFromProposed() {
645        transitionOrThrow(State.PROCEED);
646        xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
647        this.sendJingleMessage("accept", id.account.getJid().asBareJid());
648        this.sendJingleMessage("proceed");
649    }
650
651    private void rejectCallFromProposed() {
652        transitionOrThrow(State.REJECTED);
653        xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
654        this.sendJingleMessage("reject");
655        jingleConnectionManager.finishConnection(this);
656    }
657
658    private void rejectCallFromSessionInitiate() {
659        webRTCWrapper.close();
660        sendSessionTerminate(Reason.DECLINE);
661        xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
662    }
663
664    private void sendJingleMessage(final String action) {
665        sendJingleMessage(action, id.with);
666    }
667
668    private void sendJingleMessage(final String action, final Jid to) {
669        final MessagePacket messagePacket = new MessagePacket();
670        messagePacket.setType(MessagePacket.TYPE_CHAT); //we want to carbon copy those
671        messagePacket.setTo(to);
672        messagePacket.addChild(action, Namespace.JINGLE_MESSAGE).setAttribute("id", id.sessionId);
673        Log.d(Config.LOGTAG, messagePacket.toString());
674        xmppConnectionService.sendMessagePacket(id.account, messagePacket);
675    }
676
677    private void acceptCallFromSessionInitialized() {
678        xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
679        sendSessionAccept();
680    }
681
682    private synchronized boolean isInState(State... state) {
683        return Arrays.asList(state).contains(this.state);
684    }
685
686    private synchronized boolean transition(final State target) {
687        final Collection<State> validTransitions = VALID_TRANSITIONS.get(this.state);
688        if (validTransitions != null && validTransitions.contains(target)) {
689            this.state = target;
690            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": transitioned into " + target);
691            updateEndUserState();
692            return true;
693        } else {
694            return false;
695        }
696    }
697
698    public void transitionOrThrow(final State target) {
699        if (!transition(target)) {
700            throw new IllegalStateException(String.format("Unable to transition from %s to %s", this.state, target));
701        }
702    }
703
704    @Override
705    public void onIceCandidate(final IceCandidate iceCandidate) {
706        final IceUdpTransportInfo.Candidate candidate = IceUdpTransportInfo.Candidate.fromSdpAttribute(iceCandidate.sdp);
707        Log.d(Config.LOGTAG, "sending candidate: " + iceCandidate.toString());
708        sendTransportInfo(iceCandidate.sdpMid, candidate);
709    }
710
711    @Override
712    public void onConnectionChange(final PeerConnection.PeerConnectionState newState) {
713        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": PeerConnectionState changed to " + newState);
714        updateEndUserState();
715        if (newState == PeerConnection.PeerConnectionState.FAILED) { //TODO guard this in isState(initiated,initiated_approved,accepted) otherwise it might fire too late
716            sendSessionTerminate(Reason.CONNECTIVITY_ERROR);
717        }
718    }
719
720    private void updateEndUserState() {
721        xmppConnectionService.notifyJingleRtpConnectionUpdate(id.account, id.with, id.sessionId, getEndUserState());
722    }
723
724    private void discoverIceServers(final OnIceServersDiscovered onIceServersDiscovered) {
725        if (id.account.getXmppConnection().getFeatures().extendedServiceDiscovery()) {
726            final IqPacket request = new IqPacket(IqPacket.TYPE.GET);
727            request.setTo(Jid.of(id.account.getJid().getDomain()));
728            request.addChild("services", Namespace.EXTERNAL_SERVICE_DISCOVERY);
729            xmppConnectionService.sendIqPacket(id.account, request, (account, response) -> {
730                ImmutableList.Builder<PeerConnection.IceServer> listBuilder = new ImmutableList.Builder<>();
731                if (response.getType() == IqPacket.TYPE.RESULT) {
732                    final Element services = response.findChild("services", Namespace.EXTERNAL_SERVICE_DISCOVERY);
733                    final List<Element> children = services == null ? Collections.emptyList() : services.getChildren();
734                    for (final Element child : children) {
735                        if ("service".equals(child.getName())) {
736                            final String type = child.getAttribute("type");
737                            final String host = child.getAttribute("host");
738                            final String sport = child.getAttribute("port");
739                            final Integer port = sport == null ? null : Ints.tryParse(sport);
740                            final String transport = child.getAttribute("transport");
741                            final String username = child.getAttribute("username");
742                            final String password = child.getAttribute("password");
743                            if (Strings.isNullOrEmpty(host) || port == null) {
744                                continue;
745                            }
746                            if (port < 0 || port > 65535) {
747                                continue;
748                            }
749                            if (Arrays.asList("stun", "turn").contains(type) || Arrays.asList("udp", "tcp").contains(transport)) {
750                                //TODO wrap ipv6 addresses
751                                PeerConnection.IceServer.Builder iceServerBuilder = PeerConnection.IceServer.builder(String.format("%s:%s:%s?transport=%s", type, host, port, transport));
752                                if (username != null && password != null) {
753                                    iceServerBuilder.setUsername(username);
754                                    iceServerBuilder.setPassword(password);
755                                } else if (Arrays.asList("turn", "turns").contains(type)) {
756                                    //The WebRTC spec requires throwing an InvalidAccessError when username (from libwebrtc source coder)
757                                    //https://chromium.googlesource.com/external/webrtc/+/master/pc/ice_server_parsing.cc
758                                    Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": skipping " + type + "/" + transport + " without username and password");
759                                    continue;
760                                }
761                                final PeerConnection.IceServer iceServer = iceServerBuilder.createIceServer();
762                                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": discovered ICE Server: " + iceServer);
763                                listBuilder.add(iceServer);
764                            }
765                        }
766                    }
767                }
768                List<PeerConnection.IceServer> iceServers = listBuilder.build();
769                if (iceServers.size() == 0) {
770                    Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": no ICE server found " + response);
771                }
772                onIceServersDiscovered.onIceServersDiscovered(iceServers);
773            });
774        } else {
775            Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": has no external service discovery");
776            onIceServersDiscovered.onIceServersDiscovered(Collections.emptyList());
777        }
778    }
779
780    private interface OnIceServersDiscovered {
781        void onIceServersDiscovered(List<PeerConnection.IceServer> iceServers);
782    }
783}