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