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 (final 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, e.getMessage());
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, e.getMessage());
280            return;
281        }
282        sendSessionAccept(offer);
283    }
284
285    private void sendSessionAccept(SessionDescription offer) {
286        discoverIceServers(iceServers -> {
287            try {
288                setupWebRTC(iceServers);
289            } catch (WebRTCWrapper.InitializationException e) {
290                sendSessionTerminate(Reason.FAILED_APPLICATION);
291                return;
292            }
293            final org.webrtc.SessionDescription sdp = new org.webrtc.SessionDescription(
294                    org.webrtc.SessionDescription.Type.OFFER,
295                    offer.toString()
296            );
297            try {
298                this.webRTCWrapper.setRemoteDescription(sdp).get();
299                addIceCandidatesFromBlackLog();
300                org.webrtc.SessionDescription webRTCSessionDescription = this.webRTCWrapper.createAnswer().get();
301                final SessionDescription sessionDescription = SessionDescription.parse(webRTCSessionDescription.description);
302                final RtpContentMap respondingRtpContentMap = RtpContentMap.of(sessionDescription);
303                sendSessionAccept(respondingRtpContentMap);
304                this.webRTCWrapper.setLocalDescription(webRTCSessionDescription);
305            } catch (Exception e) {
306                Log.d(Config.LOGTAG, "unable to send session accept", e);
307
308            }
309        });
310    }
311
312    private void addIceCandidatesFromBlackLog() {
313        while (!this.pendingIceCandidates.isEmpty()) {
314            final IceCandidate iceCandidate = this.pendingIceCandidates.poll();
315            this.webRTCWrapper.addIceCandidate(iceCandidate);
316            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": added ICE candidate from back log " + iceCandidate);
317        }
318    }
319
320    private void sendSessionAccept(final RtpContentMap rtpContentMap) {
321        this.responderRtpContentMap = rtpContentMap;
322        this.transitionOrThrow(State.SESSION_ACCEPTED);
323        final JinglePacket sessionAccept = rtpContentMap.toJinglePacket(JinglePacket.Action.SESSION_ACCEPT, id.sessionId);
324        Log.d(Config.LOGTAG, sessionAccept.toString());
325        send(sessionAccept);
326    }
327
328    void deliveryMessage(final Jid from, final Element message) {
329        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": delivered message to JingleRtpConnection " + message);
330        switch (message.getName()) {
331            case "propose":
332                receivePropose(from, message);
333                break;
334            case "proceed":
335                receiveProceed(from, message);
336                break;
337            case "retract":
338                receiveRetract(from, message);
339                break;
340            case "reject":
341                receiveReject(from, message);
342                break;
343            case "accept":
344                receiveAccept(from, message);
345                break;
346            default:
347                break;
348        }
349    }
350
351    private void receiveAccept(Jid from, Element message) {
352        final boolean originatedFromMyself = from.asBareJid().equals(id.account.getJid().asBareJid());
353        if (originatedFromMyself) {
354            if (transition(State.ACCEPTED)) {
355                this.xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
356                this.jingleConnectionManager.finishConnection(this);
357            } else {
358                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to transition to accept because already in state=" + this.state);
359            }
360        } else {
361            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": ignoring 'accept' from " + from);
362        }
363    }
364
365    private void receiveReject(Jid from, Element message) {
366        final boolean originatedFromMyself = from.asBareJid().equals(id.account.getJid().asBareJid());
367        //reject from another one of my clients
368        if (originatedFromMyself) {
369            if (transition(State.REJECTED)) {
370                this.xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
371                this.jingleConnectionManager.finishConnection(this);
372            } else {
373                Log.d(Config.LOGTAG, "not able to transition into REJECTED because already in " + this.state);
374            }
375        } else {
376            Log.d(Config.LOGTAG, id.account.getJid() + ": ignoring reject from " + from + " for session with " + id.with);
377        }
378    }
379
380    private void receivePropose(final Jid from, final Element propose) {
381        final boolean originatedFromMyself = from.asBareJid().equals(id.account.getJid().asBareJid());
382        //TODO we can use initiator logic here
383        if (originatedFromMyself) {
384            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": saw proposal from mysql. ignoring");
385        } else if (transition(State.PROPOSED)) {
386            startRinging();
387        } else {
388            Log.d(Config.LOGTAG, id.account.getJid() + ": ignoring session proposal because already in " + state);
389        }
390    }
391
392    private void startRinging() {
393        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received call from " + id.with + ". start ringing");
394        xmppConnectionService.getNotificationService().showIncomingCallNotification(id);
395    }
396
397    private void receiveProceed(final Jid from, final Element proceed) {
398        if (from.equals(id.with)) {
399            if (isInitiator()) {
400                if (transition(State.PROCEED)) {
401                    this.sendSessionInitiate(State.SESSION_INITIALIZED_PRE_APPROVED);
402                } else {
403                    Log.d(Config.LOGTAG, String.format("%s: ignoring proceed because already in %s", id.account.getJid().asBareJid(), this.state));
404                }
405            } else {
406                Log.d(Config.LOGTAG, String.format("%s: ignoring proceed because we were not initializing", id.account.getJid().asBareJid()));
407            }
408        } else if (from.asBareJid().equals(id.account.getJid().asBareJid())) {
409            if (transition(State.ACCEPTED)) {
410                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": moved session with " + id.with + " into state accepted after received carbon copied procced");
411                this.xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
412                this.jingleConnectionManager.finishConnection(this);
413            }
414        } else {
415            Log.d(Config.LOGTAG, String.format("%s: ignoring proceed from %s. was expected from %s", id.account.getJid().asBareJid(), from, id.with));
416        }
417    }
418
419    private void receiveRetract(final Jid from, final Element retract) {
420        if (from.equals(id.with)) {
421            if (transition(State.RETRACTED)) {
422                xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
423                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": session with " + id.with + " has been retracted");
424                //TODO create missed call notification/message
425                jingleConnectionManager.finishConnection(this);
426            } else {
427                Log.d(Config.LOGTAG, "ignoring retract because already in " + this.state);
428            }
429        } else {
430            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received retract from " + from + ". expected retract from" + id.with + ". ignoring");
431        }
432    }
433
434    private void sendSessionInitiate(final State targetState) {
435        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": prepare session-initiate");
436        discoverIceServers(iceServers -> {
437            try {
438                setupWebRTC(iceServers);
439            } catch (WebRTCWrapper.InitializationException e) {
440                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to initialize webrtc");
441                transitionOrThrow(State.TERMINATED_APPLICATION_FAILURE);
442                return;
443            }
444            try {
445                org.webrtc.SessionDescription webRTCSessionDescription = this.webRTCWrapper.createOffer().get();
446                final SessionDescription sessionDescription = SessionDescription.parse(webRTCSessionDescription.description);
447                Log.d(Config.LOGTAG, "description: " + webRTCSessionDescription.description);
448                final RtpContentMap rtpContentMap = RtpContentMap.of(sessionDescription);
449                sendSessionInitiate(rtpContentMap, targetState);
450                this.webRTCWrapper.setLocalDescription(webRTCSessionDescription).get();
451            } catch (final Exception e) {
452                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to sendSessionInitiate", e);
453                webRTCWrapper.close();
454                if (isInState(targetState)) {
455                    sendSessionTerminate(Reason.FAILED_APPLICATION);
456                } else {
457                    transitionOrThrow(State.TERMINATED_APPLICATION_FAILURE);
458                }
459            }
460        });
461    }
462
463    private void sendSessionInitiate(RtpContentMap rtpContentMap, final State targetState) {
464        this.initiatorRtpContentMap = rtpContentMap;
465        this.transitionOrThrow(targetState);
466        final JinglePacket sessionInitiate = rtpContentMap.toJinglePacket(JinglePacket.Action.SESSION_INITIATE, id.sessionId);
467        Log.d(Config.LOGTAG, sessionInitiate.toString());
468        send(sessionInitiate);
469    }
470
471    private void sendSessionTerminate(final Reason reason) {
472        sendSessionTerminate(reason, null);
473    }
474
475    private void sendSessionTerminate(final Reason reason, final String text) {
476        final State target = reasonToState(reason);
477        transitionOrThrow(target);
478        final JinglePacket jinglePacket = new JinglePacket(JinglePacket.Action.SESSION_TERMINATE, id.sessionId);
479        jinglePacket.setReason(reason, text);
480        send(jinglePacket);
481        Log.d(Config.LOGTAG, jinglePacket.toString());
482        jingleConnectionManager.finishConnection(this);
483    }
484
485    private void sendTransportInfo(final String contentName, IceUdpTransportInfo.Candidate candidate) {
486        final RtpContentMap transportInfo;
487        try {
488            final RtpContentMap rtpContentMap = isInitiator() ? this.initiatorRtpContentMap : this.responderRtpContentMap;
489            transportInfo = rtpContentMap.transportInfo(contentName, candidate);
490        } catch (Exception e) {
491            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to prepare transport-info from candidate for content=" + contentName);
492            return;
493        }
494        final JinglePacket jinglePacket = transportInfo.toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
495        Log.d(Config.LOGTAG, jinglePacket.toString());
496        send(jinglePacket);
497    }
498
499    private void send(final JinglePacket jinglePacket) {
500        jinglePacket.setTo(id.with);
501        xmppConnectionService.sendIqPacket(id.account, jinglePacket, (account, response) -> {
502            if (response.getType() == IqPacket.TYPE.ERROR) {
503                final String errorCondition = response.getErrorCondition();
504                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received IQ-error from " + response.getFrom() + " in RTP session. " + errorCondition);
505                this.webRTCWrapper.close();
506                final State target;
507                if (Arrays.asList(
508                        "service-unavailable",
509                        "recipient-unavailable",
510                        "remote-server-not-found",
511                        "remote-server-timeout"
512                ).contains(errorCondition)) {
513                    target = State.TERMINATED_CONNECTIVITY_ERROR;
514                } else {
515                    target = State.TERMINATED_APPLICATION_FAILURE;
516                }
517                if (transition(target)) {
518                    Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": terminated session with " + id.with);
519                } else {
520                    Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": not transitioning because already at state=" + this.state);
521                }
522
523            } else if (response.getType() == IqPacket.TYPE.TIMEOUT) {
524                this.webRTCWrapper.close();
525                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received IQ timeout in RTP session with " + id.with + ". terminating with connectivity error");
526                transition(State.TERMINATED_CONNECTIVITY_ERROR);
527                this.jingleConnectionManager.finishConnection(this);
528            }
529        });
530    }
531
532    private void respondWithOutOfOrder(final JinglePacket jinglePacket) {
533        jingleConnectionManager.respondWithJingleError(id.account, jinglePacket, "out-of-order", "unexpected-request", "wait");
534    }
535
536    private void respondOk(final JinglePacket jinglePacket) {
537        xmppConnectionService.sendIqPacket(id.account, jinglePacket.generateResponse(IqPacket.TYPE.RESULT), null);
538    }
539
540    public RtpEndUserState getEndUserState() {
541        switch (this.state) {
542            case PROPOSED:
543            case SESSION_INITIALIZED:
544                if (isInitiator()) {
545                    return RtpEndUserState.RINGING;
546                } else {
547                    return RtpEndUserState.INCOMING_CALL;
548                }
549            case PROCEED:
550                if (isInitiator()) {
551                    return RtpEndUserState.RINGING;
552                } else {
553                    return RtpEndUserState.ACCEPTING_CALL;
554                }
555            case SESSION_INITIALIZED_PRE_APPROVED:
556                if (isInitiator()) {
557                    return RtpEndUserState.RINGING;
558                } else {
559                    return RtpEndUserState.CONNECTING;
560                }
561            case SESSION_ACCEPTED:
562                final PeerConnection.PeerConnectionState state = webRTCWrapper.getState();
563                if (state == PeerConnection.PeerConnectionState.CONNECTED) {
564                    return RtpEndUserState.CONNECTED;
565                } else if (state == PeerConnection.PeerConnectionState.NEW || state == PeerConnection.PeerConnectionState.CONNECTING) {
566                    return RtpEndUserState.CONNECTING;
567                } else if (state == PeerConnection.PeerConnectionState.CLOSED) {
568                    return RtpEndUserState.ENDING_CALL;
569                } else if (state == PeerConnection.PeerConnectionState.FAILED) {
570                    return RtpEndUserState.CONNECTIVITY_ERROR;
571                } else {
572                    return RtpEndUserState.ENDING_CALL;
573                }
574            case REJECTED:
575            case TERMINATED_DECLINED_OR_BUSY:
576                if (isInitiator()) {
577                    return RtpEndUserState.DECLINED_OR_BUSY;
578                } else {
579                    return RtpEndUserState.ENDED;
580                }
581            case TERMINATED_SUCCESS:
582            case ACCEPTED:
583            case RETRACTED:
584            case TERMINATED_CANCEL_OR_TIMEOUT:
585                return RtpEndUserState.ENDED;
586            case TERMINATED_CONNECTIVITY_ERROR:
587                return RtpEndUserState.CONNECTIVITY_ERROR;
588            case TERMINATED_APPLICATION_FAILURE:
589                return RtpEndUserState.APPLICATION_ERROR;
590        }
591        throw new IllegalStateException(String.format("%s has no equivalent EndUserState", this.state));
592    }
593
594
595    public void acceptCall() {
596        switch (this.state) {
597            case PROPOSED:
598                acceptCallFromProposed();
599                break;
600            case SESSION_INITIALIZED:
601                acceptCallFromSessionInitialized();
602                break;
603            default:
604                throw new IllegalStateException("Can not accept call from " + this.state);
605        }
606    }
607
608    public void rejectCall() {
609        switch (this.state) {
610            case PROPOSED:
611                rejectCallFromProposed();
612                break;
613            case SESSION_INITIALIZED:
614                rejectCallFromSessionInitiate();
615                break;
616            default:
617                throw new IllegalStateException("Can not reject call from " + this.state);
618        }
619    }
620
621    public void endCall() {
622        if (isInState(State.PROCEED)) {
623            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": ending call while in state PROCEED just means ending the connection");
624            webRTCWrapper.close();
625            jingleConnectionManager.finishConnection(this);
626            transitionOrThrow(State.TERMINATED_SUCCESS); //arguably this wasn't success; but not a real failure either
627            return;
628        }
629        if (isInitiator() && isInState(State.SESSION_INITIALIZED, State.SESSION_INITIALIZED_PRE_APPROVED)) {
630            webRTCWrapper.close();
631            sendSessionTerminate(Reason.CANCEL);
632            return;
633        }
634        if (isInState(State.SESSION_INITIALIZED, State.SESSION_INITIALIZED_PRE_APPROVED, State.SESSION_ACCEPTED)) {
635            webRTCWrapper.close();
636            sendSessionTerminate(Reason.SUCCESS);
637            return;
638        }
639        throw new IllegalStateException("called 'endCall' while in state " + this.state);
640    }
641
642    private void setupWebRTC(final List<PeerConnection.IceServer> iceServers) throws WebRTCWrapper.InitializationException {
643        this.webRTCWrapper.setup(this.xmppConnectionService);
644        this.webRTCWrapper.initializePeerConnection(iceServers);
645    }
646
647    private void acceptCallFromProposed() {
648        transitionOrThrow(State.PROCEED);
649        xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
650        this.sendJingleMessage("accept", id.account.getJid().asBareJid());
651        this.sendJingleMessage("proceed");
652    }
653
654    private void rejectCallFromProposed() {
655        transitionOrThrow(State.REJECTED);
656        xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
657        this.sendJingleMessage("reject");
658        jingleConnectionManager.finishConnection(this);
659    }
660
661    private void rejectCallFromSessionInitiate() {
662        webRTCWrapper.close();
663        sendSessionTerminate(Reason.DECLINE);
664        xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
665    }
666
667    private void sendJingleMessage(final String action) {
668        sendJingleMessage(action, id.with);
669    }
670
671    private void sendJingleMessage(final String action, final Jid to) {
672        final MessagePacket messagePacket = new MessagePacket();
673        messagePacket.setType(MessagePacket.TYPE_CHAT); //we want to carbon copy those
674        messagePacket.setTo(to);
675        messagePacket.addChild(action, Namespace.JINGLE_MESSAGE).setAttribute("id", id.sessionId);
676        Log.d(Config.LOGTAG, messagePacket.toString());
677        xmppConnectionService.sendMessagePacket(id.account, messagePacket);
678    }
679
680    private void acceptCallFromSessionInitialized() {
681        xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
682        sendSessionAccept();
683    }
684
685    private synchronized boolean isInState(State... state) {
686        return Arrays.asList(state).contains(this.state);
687    }
688
689    private synchronized boolean transition(final State target) {
690        final Collection<State> validTransitions = VALID_TRANSITIONS.get(this.state);
691        if (validTransitions != null && validTransitions.contains(target)) {
692            this.state = target;
693            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": transitioned into " + target);
694            updateEndUserState();
695            return true;
696        } else {
697            return false;
698        }
699    }
700
701    public void transitionOrThrow(final State target) {
702        if (!transition(target)) {
703            throw new IllegalStateException(String.format("Unable to transition from %s to %s", this.state, target));
704        }
705    }
706
707    @Override
708    public void onIceCandidate(final IceCandidate iceCandidate) {
709        final IceUdpTransportInfo.Candidate candidate = IceUdpTransportInfo.Candidate.fromSdpAttribute(iceCandidate.sdp);
710        Log.d(Config.LOGTAG, "sending candidate: " + iceCandidate.toString());
711        sendTransportInfo(iceCandidate.sdpMid, candidate);
712    }
713
714    @Override
715    public void onConnectionChange(final PeerConnection.PeerConnectionState newState) {
716        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": PeerConnectionState changed to " + newState);
717        updateEndUserState();
718        if (newState == PeerConnection.PeerConnectionState.FAILED) { //TODO guard this in isState(initiated,initiated_approved,accepted) otherwise it might fire too late
719            sendSessionTerminate(Reason.CONNECTIVITY_ERROR);
720        }
721    }
722
723    private void updateEndUserState() {
724        xmppConnectionService.notifyJingleRtpConnectionUpdate(id.account, id.with, id.sessionId, getEndUserState());
725    }
726
727    private void discoverIceServers(final OnIceServersDiscovered onIceServersDiscovered) {
728        if (id.account.getXmppConnection().getFeatures().extendedServiceDiscovery()) {
729            final IqPacket request = new IqPacket(IqPacket.TYPE.GET);
730            request.setTo(Jid.of(id.account.getJid().getDomain()));
731            request.addChild("services", Namespace.EXTERNAL_SERVICE_DISCOVERY);
732            xmppConnectionService.sendIqPacket(id.account, request, (account, response) -> {
733                ImmutableList.Builder<PeerConnection.IceServer> listBuilder = new ImmutableList.Builder<>();
734                if (response.getType() == IqPacket.TYPE.RESULT) {
735                    final Element services = response.findChild("services", Namespace.EXTERNAL_SERVICE_DISCOVERY);
736                    final List<Element> children = services == null ? Collections.emptyList() : services.getChildren();
737                    for (final Element child : children) {
738                        if ("service".equals(child.getName())) {
739                            final String type = child.getAttribute("type");
740                            final String host = child.getAttribute("host");
741                            final String sport = child.getAttribute("port");
742                            final Integer port = sport == null ? null : Ints.tryParse(sport);
743                            final String transport = child.getAttribute("transport");
744                            final String username = child.getAttribute("username");
745                            final String password = child.getAttribute("password");
746                            if (Strings.isNullOrEmpty(host) || port == null) {
747                                continue;
748                            }
749                            if (port < 0 || port > 65535) {
750                                continue;
751                            }
752                            if (Arrays.asList("stun", "turn").contains(type) || Arrays.asList("udp", "tcp").contains(transport)) {
753                                //TODO wrap ipv6 addresses
754                                PeerConnection.IceServer.Builder iceServerBuilder = PeerConnection.IceServer.builder(String.format("%s:%s:%s?transport=%s", type, host, port, transport));
755                                if (username != null && password != null) {
756                                    iceServerBuilder.setUsername(username);
757                                    iceServerBuilder.setPassword(password);
758                                } else if (Arrays.asList("turn", "turns").contains(type)) {
759                                    //The WebRTC spec requires throwing an InvalidAccessError when username (from libwebrtc source coder)
760                                    //https://chromium.googlesource.com/external/webrtc/+/master/pc/ice_server_parsing.cc
761                                    Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": skipping " + type + "/" + transport + " without username and password");
762                                    continue;
763                                }
764                                final PeerConnection.IceServer iceServer = iceServerBuilder.createIceServer();
765                                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": discovered ICE Server: " + iceServer);
766                                listBuilder.add(iceServer);
767                            }
768                        }
769                    }
770                }
771                List<PeerConnection.IceServer> iceServers = listBuilder.build();
772                if (iceServers.size() == 0) {
773                    Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": no ICE server found " + response);
774                }
775                onIceServersDiscovered.onIceServersDiscovered(iceServers);
776            });
777        } else {
778            Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": has no external service discovery");
779            onIceServersDiscovered.onIceServersDiscovered(Collections.emptyList());
780        }
781    }
782
783    private interface OnIceServersDiscovered {
784        void onIceServersDiscovered(List<PeerConnection.IceServer> iceServers);
785    }
786}