JingleRtpConnection.java

  1package eu.siacs.conversations.xmpp.jingle;
  2
  3import android.util.Log;
  4
  5import com.google.common.collect.ImmutableList;
  6import com.google.common.collect.ImmutableMap;
  7
  8import org.webrtc.IceCandidate;
  9import org.webrtc.PeerConnection;
 10
 11import java.util.ArrayDeque;
 12import java.util.Arrays;
 13import java.util.Collection;
 14import java.util.Collections;
 15import java.util.List;
 16import java.util.Map;
 17
 18import eu.siacs.conversations.Config;
 19import eu.siacs.conversations.xml.Element;
 20import eu.siacs.conversations.xml.Namespace;
 21import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
 22import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
 23import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
 24import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
 25import eu.siacs.conversations.xmpp.stanzas.MessagePacket;
 26import rocks.xmpp.addr.Jid;
 27
 28public class JingleRtpConnection extends AbstractJingleConnection implements WebRTCWrapper.EventCallback {
 29
 30    private static final Map<State, Collection<State>> VALID_TRANSITIONS;
 31
 32    static {
 33        final ImmutableMap.Builder<State, Collection<State>> transitionBuilder = new ImmutableMap.Builder<>();
 34        transitionBuilder.put(State.NULL, ImmutableList.of(State.PROPOSED, State.SESSION_INITIALIZED));
 35        transitionBuilder.put(State.PROPOSED, ImmutableList.of(State.ACCEPTED, State.PROCEED, State.REJECTED, State.RETRACTED));
 36        transitionBuilder.put(State.PROCEED, ImmutableList.of(State.SESSION_INITIALIZED_PRE_APPROVED));
 37        transitionBuilder.put(State.SESSION_INITIALIZED, ImmutableList.of(State.SESSION_ACCEPTED, State.TERMINATED_CANCEL_OR_TIMEOUT));
 38        transitionBuilder.put(State.SESSION_INITIALIZED_PRE_APPROVED, ImmutableList.of(State.SESSION_ACCEPTED, State.TERMINATED_CANCEL_OR_TIMEOUT));
 39        transitionBuilder.put(State.SESSION_ACCEPTED, ImmutableList.of(State.TERMINATED_SUCCESS, State.TERMINATED_CONNECTIVITY_ERROR));
 40        VALID_TRANSITIONS = transitionBuilder.build();
 41    }
 42
 43    private final WebRTCWrapper webRTCWrapper = new WebRTCWrapper(this);
 44    private final ArrayDeque<IceCandidate> pendingIceCandidates = new ArrayDeque<>();
 45    private State state = State.NULL;
 46    private RtpContentMap initiatorRtpContentMap;
 47    private RtpContentMap responderRtpContentMap;
 48
 49
 50    public JingleRtpConnection(JingleConnectionManager jingleConnectionManager, Id id, Jid initiator) {
 51        super(jingleConnectionManager, id, initiator);
 52    }
 53
 54    @Override
 55    void deliverPacket(final JinglePacket jinglePacket) {
 56        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": packet delivered to JingleRtpConnection");
 57        switch (jinglePacket.getAction()) {
 58            case SESSION_INITIATE:
 59                receiveSessionInitiate(jinglePacket);
 60                break;
 61            case TRANSPORT_INFO:
 62                receiveTransportInfo(jinglePacket);
 63                break;
 64            case SESSION_ACCEPT:
 65                receiveSessionAccept(jinglePacket);
 66                break;
 67            case SESSION_TERMINATE:
 68                receiveSessionTerminate(jinglePacket);
 69                break;
 70            default:
 71                Log.d(Config.LOGTAG, String.format("%s: received unhandled jingle action %s", id.account.getJid().asBareJid(), jinglePacket.getAction()));
 72                break;
 73        }
 74    }
 75
 76    private void receiveSessionTerminate(final JinglePacket jinglePacket) {
 77        final Reason reason = jinglePacket.getReason();
 78        final State previous = this.state;
 79        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received session terminate reason=" + reason + " while in state " + previous);
 80        webRTCWrapper.close();
 81        transitionOrThrow(reasonToState(reason));
 82        if (previous == State.PROPOSED || previous == State.SESSION_INITIALIZED) {
 83            xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
 84        }
 85        jingleConnectionManager.finishConnection(this);
 86    }
 87
 88    private static State reasonToState(Reason reason) {
 89        switch (reason) {
 90            case SUCCESS:
 91                return State.TERMINATED_SUCCESS;
 92            case DECLINE:
 93            case BUSY:
 94                return State.TERMINATED_DECLINED_OR_BUSY;
 95            case CANCEL:
 96            case TIMEOUT:
 97                return State.TERMINATED_CANCEL_OR_TIMEOUT;
 98            default:
 99                return State.TERMINATED_CONNECTIVITY_ERROR;
100        }
101    }
102
103    private void receiveTransportInfo(final JinglePacket jinglePacket) {
104        if (isInState(State.SESSION_INITIALIZED, State.SESSION_INITIALIZED_PRE_APPROVED, State.SESSION_ACCEPTED)) {
105            final RtpContentMap contentMap;
106            try {
107                contentMap = RtpContentMap.of(jinglePacket);
108            } catch (IllegalArgumentException | NullPointerException e) {
109                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": improperly formatted contents", e);
110                return;
111            }
112            final RtpContentMap rtpContentMap = isInitiator() ? this.initiatorRtpContentMap : this.responderRtpContentMap;
113            final Group originalGroup = rtpContentMap != null ? rtpContentMap.group : null;
114            final List<String> identificationTags = originalGroup == null ? Collections.emptyList() : originalGroup.getIdentificationTags();
115            if (identificationTags.size() == 0) {
116                Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": no identification tags found in initial offer. we won't be able to calculate mLineIndices");
117            }
118            for (final Map.Entry<String, RtpContentMap.DescriptionTransport> content : contentMap.contents.entrySet()) {
119                final String ufrag = content.getValue().transport.getAttribute("ufrag");
120                for (final IceUdpTransportInfo.Candidate candidate : content.getValue().transport.getCandidates()) {
121                    final String sdp = candidate.toSdpAttribute(ufrag);
122                    final String sdpMid = content.getKey();
123                    final int mLineIndex = identificationTags.indexOf(sdpMid);
124                    final IceCandidate iceCandidate = new IceCandidate(sdpMid, mLineIndex, sdp);
125                    Log.d(Config.LOGTAG, "received candidate: " + iceCandidate);
126                    if (isInState(State.SESSION_ACCEPTED)) {
127                        this.webRTCWrapper.addIceCandidate(iceCandidate);
128                    } else {
129                        this.pendingIceCandidates.push(iceCandidate);
130                    }
131                }
132            }
133        } else {
134            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received transport info while in state=" + this.state);
135        }
136    }
137
138    private void receiveSessionInitiate(final JinglePacket jinglePacket) {
139        if (isInitiator()) {
140            Log.d(Config.LOGTAG, String.format("%s: received session-initiate even though we were initiating", id.account.getJid().asBareJid()));
141            //TODO respond with out-of-order
142            return;
143        }
144        final RtpContentMap contentMap;
145        try {
146            contentMap = RtpContentMap.of(jinglePacket);
147            contentMap.requireContentDescriptions();
148        } catch (IllegalArgumentException | IllegalStateException | NullPointerException e) {
149            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": improperly formatted contents", e);
150            return;
151        }
152        Log.d(Config.LOGTAG, "processing session-init with " + contentMap.contents.size() + " contents");
153        final State target;
154        if (this.state == State.PROCEED) {
155            target = State.SESSION_INITIALIZED_PRE_APPROVED;
156        } else {
157            target = State.SESSION_INITIALIZED;
158        }
159        if (transition(target)) {
160            this.initiatorRtpContentMap = contentMap;
161            if (target == State.SESSION_INITIALIZED_PRE_APPROVED) {
162                Log.d(Config.LOGTAG, "automatically accepting");
163                sendSessionAccept();
164            } else {
165                Log.d(Config.LOGTAG, "start ringing");
166                //TODO start ringing
167            }
168        } else {
169            Log.d(Config.LOGTAG, String.format("%s: received session-initiate while in state %s", id.account.getJid().asBareJid(), state));
170        }
171    }
172
173    private void receiveSessionAccept(final JinglePacket jinglePacket) {
174        if (!isInitiator()) {
175            Log.d(Config.LOGTAG, String.format("%s: received session-accept even though we were responding", id.account.getJid().asBareJid()));
176            //TODO respond with out-of-order
177            return;
178        }
179        final RtpContentMap contentMap;
180        try {
181            contentMap = RtpContentMap.of(jinglePacket);
182            contentMap.requireContentDescriptions();
183        } catch (IllegalArgumentException | IllegalStateException | NullPointerException e) {
184            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": improperly formatted contents", e);
185            return;
186        }
187        Log.d(Config.LOGTAG, "processing session-accept with " + contentMap.contents.size() + " contents");
188        if (transition(State.SESSION_ACCEPTED)) {
189            receiveSessionAccept(contentMap);
190        } else {
191            Log.d(Config.LOGTAG, String.format("%s: received session-accept while in state %s", id.account.getJid().asBareJid(), state));
192            //TODO out-of-order
193        }
194    }
195
196    private void receiveSessionAccept(final RtpContentMap contentMap) {
197        this.responderRtpContentMap = contentMap;
198        org.webrtc.SessionDescription answer = new org.webrtc.SessionDescription(
199                org.webrtc.SessionDescription.Type.ANSWER,
200                SessionDescription.of(contentMap).toString()
201        );
202        try {
203            this.webRTCWrapper.setRemoteDescription(answer).get();
204        } catch (Exception e) {
205            Log.d(Config.LOGTAG, "unable to receive session accept", e);
206        }
207    }
208
209    private void sendSessionAccept() {
210        final RtpContentMap rtpContentMap = this.initiatorRtpContentMap;
211        if (rtpContentMap == null) {
212            throw new IllegalStateException("initiator RTP Content Map has not been set");
213        }
214        setupWebRTC();
215        final org.webrtc.SessionDescription offer = new org.webrtc.SessionDescription(
216                org.webrtc.SessionDescription.Type.OFFER,
217                SessionDescription.of(rtpContentMap).toString()
218        );
219        try {
220            this.webRTCWrapper.setRemoteDescription(offer).get();
221            org.webrtc.SessionDescription webRTCSessionDescription = this.webRTCWrapper.createAnswer().get();
222            final SessionDescription sessionDescription = SessionDescription.parse(webRTCSessionDescription.description);
223            final RtpContentMap respondingRtpContentMap = RtpContentMap.of(sessionDescription);
224            sendSessionAccept(respondingRtpContentMap);
225            this.webRTCWrapper.setLocalDescription(webRTCSessionDescription);
226        } catch (Exception e) {
227            Log.d(Config.LOGTAG, "unable to send session accept", e);
228
229        }
230    }
231
232    private void sendSessionAccept(final RtpContentMap rtpContentMap) {
233        this.responderRtpContentMap = rtpContentMap;
234        this.transitionOrThrow(State.SESSION_ACCEPTED);
235        final JinglePacket sessionAccept = rtpContentMap.toJinglePacket(JinglePacket.Action.SESSION_ACCEPT, id.sessionId);
236        Log.d(Config.LOGTAG, sessionAccept.toString());
237        send(sessionAccept);
238    }
239
240    void deliveryMessage(final Jid from, final Element message) {
241        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": delivered message to JingleRtpConnection " + message);
242        switch (message.getName()) {
243            case "propose":
244                receivePropose(from, message);
245                break;
246            case "proceed":
247                receiveProceed(from, message);
248                break;
249            case "retract":
250                receiveRetract(from, message);
251                break;
252            case "reject":
253                receiveReject(from, message);
254                break;
255            case "accept":
256                receiveAccept(from, message);
257                break;
258            default:
259                break;
260        }
261    }
262
263    private void receiveAccept(Jid from, Element message) {
264        final boolean originatedFromMyself = from.asBareJid().equals(id.account.getJid().asBareJid());
265        if (originatedFromMyself) {
266            if (transition(State.ACCEPTED)) {
267                this.xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
268                this.jingleConnectionManager.finishConnection(this);
269            } else {
270                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to transition to accept because already in state=" + this.state);
271            }
272        } else {
273            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": ignoring 'accept' from " + from);
274        }
275    }
276
277    private void receiveReject(Jid from, Element message) {
278        final boolean originatedFromMyself = from.asBareJid().equals(id.account.getJid().asBareJid());
279        //reject from another one of my clients
280        if (originatedFromMyself) {
281            if (transition(State.REJECTED)) {
282                this.xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
283                this.jingleConnectionManager.finishConnection(this);
284            } else {
285                Log.d(Config.LOGTAG, "not able to transition into REJECTED because already in " + this.state);
286            }
287        } else {
288            Log.d(Config.LOGTAG, id.account.getJid() + ": ignoring reject from " + from + " for session with " + id.with);
289        }
290    }
291
292    private void receivePropose(final Jid from, final Element propose) {
293        final boolean originatedFromMyself = from.asBareJid().equals(id.account.getJid().asBareJid());
294        //TODO we can use initiator logic here
295        if (originatedFromMyself) {
296            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": saw proposal from mysql. ignoring");
297        } else if (transition(State.PROPOSED)) {
298            startRinging();
299        } else {
300            Log.d(Config.LOGTAG, id.account.getJid() + ": ignoring session proposal because already in " + state);
301        }
302    }
303
304    private void startRinging() {
305        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received call from " + id.with + ". start ringing");
306        xmppConnectionService.getNotificationService().showIncomingCallNotification(id);
307    }
308
309    private void receiveProceed(final Jid from, final Element proceed) {
310        if (from.equals(id.with)) {
311            if (isInitiator()) {
312                if (transition(State.PROCEED)) {
313                    this.sendSessionInitiate(State.SESSION_INITIALIZED_PRE_APPROVED);
314                } else {
315                    Log.d(Config.LOGTAG, String.format("%s: ignoring proceed because already in %s", id.account.getJid().asBareJid(), this.state));
316                }
317            } else {
318                Log.d(Config.LOGTAG, String.format("%s: ignoring proceed because we were not initializing", id.account.getJid().asBareJid()));
319            }
320        } else if (from.asBareJid().equals(id.account.getJid().asBareJid())) {
321            if (transition(State.ACCEPTED)) {
322                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": moved session with " + id.with + " into state accepted after received carbon copied procced");
323                this.xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
324                this.jingleConnectionManager.finishConnection(this);
325            }
326        } else {
327            //TODO a carbon copied proceed from another client of mine has the same logic as `accept`
328            Log.d(Config.LOGTAG, String.format("%s: ignoring proceed from %s. was expected from %s", id.account.getJid().asBareJid(), from, id.with));
329        }
330    }
331
332    private void receiveRetract(final Jid from, final Element retract) {
333        if (from.equals(id.with)) {
334            if (transition(State.RETRACTED)) {
335                xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
336                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": session with " + id.with + " has been retracted");
337                //TODO create missed call notification/message
338                jingleConnectionManager.finishConnection(this);
339            } else {
340                Log.d(Config.LOGTAG, "ignoring retract because already in " + this.state);
341            }
342        } else {
343            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received retract from " + from + ". expected retract from" + id.with + ". ignoring");
344        }
345    }
346
347    private void sendSessionInitiate(final State targetState) {
348        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": prepare session-initiate");
349        setupWebRTC();
350        try {
351            org.webrtc.SessionDescription webRTCSessionDescription = this.webRTCWrapper.createOffer().get();
352            final SessionDescription sessionDescription = SessionDescription.parse(webRTCSessionDescription.description);
353            Log.d(Config.LOGTAG, "description: " + webRTCSessionDescription.description);
354            final RtpContentMap rtpContentMap = RtpContentMap.of(sessionDescription);
355            sendSessionInitiate(rtpContentMap, targetState);
356            this.webRTCWrapper.setLocalDescription(webRTCSessionDescription).get();
357        } catch (Exception e) {
358            Log.d(Config.LOGTAG, "unable to sendSessionInitiate", e);
359        }
360    }
361
362    private void sendSessionInitiate(RtpContentMap rtpContentMap, final State targetState) {
363        this.initiatorRtpContentMap = rtpContentMap;
364        this.transitionOrThrow(targetState);
365        final JinglePacket sessionInitiate = rtpContentMap.toJinglePacket(JinglePacket.Action.SESSION_INITIATE, id.sessionId);
366        Log.d(Config.LOGTAG, sessionInitiate.toString());
367        send(sessionInitiate);
368    }
369
370    private void sendSessionTerminate(final Reason reason) {
371        final State target = reasonToState(reason);
372        transitionOrThrow(target);
373        final JinglePacket jinglePacket = new JinglePacket(JinglePacket.Action.SESSION_TERMINATE, id.sessionId);
374        jinglePacket.setReason(reason);
375        send(jinglePacket);
376        Log.d(Config.LOGTAG, jinglePacket.toString());
377        jingleConnectionManager.finishConnection(this);
378    }
379
380    private void sendTransportInfo(final String contentName, IceUdpTransportInfo.Candidate candidate) {
381        final RtpContentMap transportInfo;
382        try {
383            final RtpContentMap rtpContentMap = isInitiator() ? this.initiatorRtpContentMap : this.responderRtpContentMap;
384            transportInfo = rtpContentMap.transportInfo(contentName, candidate);
385        } catch (Exception e) {
386            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to prepare transport-info from candidate for content=" + contentName);
387            return;
388        }
389        final JinglePacket jinglePacket = transportInfo.toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
390        Log.d(Config.LOGTAG, jinglePacket.toString());
391        send(jinglePacket);
392    }
393
394    private void send(final JinglePacket jinglePacket) {
395        jinglePacket.setTo(id.with);
396        //TODO track errors
397        xmppConnectionService.sendIqPacket(id.account, jinglePacket, null);
398    }
399
400    public RtpEndUserState getEndUserState() {
401        switch (this.state) {
402            case PROPOSED:
403            case SESSION_INITIALIZED:
404                if (isInitiator()) {
405                    return RtpEndUserState.RINGING;
406                } else {
407                    return RtpEndUserState.INCOMING_CALL;
408                }
409            case PROCEED:
410                if (isInitiator()) {
411                    return RtpEndUserState.CONNECTING;
412                } else {
413                    return RtpEndUserState.ACCEPTING_CALL;
414                }
415            case SESSION_INITIALIZED_PRE_APPROVED:
416                return RtpEndUserState.CONNECTING;
417            case SESSION_ACCEPTED:
418                final PeerConnection.PeerConnectionState state = webRTCWrapper.getState();
419                if (state == PeerConnection.PeerConnectionState.CONNECTED) {
420                    return RtpEndUserState.CONNECTED;
421                } else if (state == PeerConnection.PeerConnectionState.NEW || state == PeerConnection.PeerConnectionState.CONNECTING) {
422                    return RtpEndUserState.CONNECTING;
423                } else if (state == PeerConnection.PeerConnectionState.CLOSED) {
424                    return RtpEndUserState.ENDING_CALL;
425                } else if (state == PeerConnection.PeerConnectionState.FAILED) {
426                    return RtpEndUserState.CONNECTIVITY_ERROR;
427                } else {
428                    return RtpEndUserState.ENDING_CALL;
429                }
430            case REJECTED:
431            case TERMINATED_DECLINED_OR_BUSY:
432                if (isInitiator()) {
433                    return RtpEndUserState.DECLINED_OR_BUSY;
434                } else {
435                    return RtpEndUserState.ENDED;
436                }
437            case TERMINATED_SUCCESS:
438            case ACCEPTED:
439            case RETRACTED:
440            case TERMINATED_CANCEL_OR_TIMEOUT:
441                return RtpEndUserState.ENDED;
442            case TERMINATED_CONNECTIVITY_ERROR:
443                return RtpEndUserState.CONNECTIVITY_ERROR;
444        }
445        throw new IllegalStateException(String.format("%s has no equivalent EndUserState", this.state));
446    }
447
448
449    public void acceptCall() {
450        switch (this.state) {
451            case PROPOSED:
452                acceptCallFromProposed();
453                break;
454            case SESSION_INITIALIZED:
455                acceptCallFromSessionInitialized();
456                break;
457            default:
458                throw new IllegalStateException("Can not accept call from " + this.state);
459        }
460    }
461
462    public void rejectCall() {
463        switch (this.state) {
464            case PROPOSED:
465                rejectCallFromProposed();
466                break;
467            default:
468                throw new IllegalStateException("Can not reject call from " + this.state);
469        }
470    }
471
472    public void endCall() {
473        if (isInitiator() && isInState(State.SESSION_INITIALIZED)) {
474            webRTCWrapper.close();
475            sendSessionTerminate(Reason.CANCEL);
476        } else if (isInState(State.SESSION_INITIALIZED, State.SESSION_INITIALIZED_PRE_APPROVED, State.SESSION_ACCEPTED)) {
477            webRTCWrapper.close();
478            sendSessionTerminate(Reason.SUCCESS);
479        } else {
480            throw new IllegalStateException("called 'endCall' while in state " + this.state);
481        }
482    }
483
484    private void setupWebRTC() {
485        this.webRTCWrapper.setup(this.xmppConnectionService);
486        this.webRTCWrapper.initializePeerConnection();
487    }
488
489    private void acceptCallFromProposed() {
490        transitionOrThrow(State.PROCEED);
491        xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
492        this.sendJingleMessage("accept", id.account.getJid().asBareJid());
493        this.sendJingleMessage("proceed");
494    }
495
496    private void rejectCallFromProposed() {
497        transitionOrThrow(State.REJECTED);
498        xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
499        this.sendJingleMessage("reject");
500        jingleConnectionManager.finishConnection(this);
501    }
502
503    private void sendJingleMessage(final String action) {
504        sendJingleMessage(action, id.with);
505    }
506
507    private void sendJingleMessage(final String action, final Jid to) {
508        final MessagePacket messagePacket = new MessagePacket();
509        messagePacket.setType(MessagePacket.TYPE_CHAT); //we want to carbon copy those
510        messagePacket.setTo(to);
511        messagePacket.addChild(action, Namespace.JINGLE_MESSAGE).setAttribute("id", id.sessionId);
512        Log.d(Config.LOGTAG, messagePacket.toString());
513        xmppConnectionService.sendMessagePacket(id.account, messagePacket);
514    }
515
516    private void acceptCallFromSessionInitialized() {
517        xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
518        throw new IllegalStateException("accepting from this state has not been implemented yet");
519    }
520
521    private synchronized boolean isInState(State... state) {
522        return Arrays.asList(state).contains(this.state);
523    }
524
525    private synchronized boolean transition(final State target) {
526        final Collection<State> validTransitions = VALID_TRANSITIONS.get(this.state);
527        if (validTransitions != null && validTransitions.contains(target)) {
528            this.state = target;
529            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": transitioned into " + target);
530            updateEndUserState();
531            return true;
532        } else {
533            return false;
534        }
535    }
536
537    public void transitionOrThrow(final State target) {
538        if (!transition(target)) {
539            throw new IllegalStateException(String.format("Unable to transition from %s to %s", this.state, target));
540        }
541    }
542
543    @Override
544    public void onIceCandidate(final IceCandidate iceCandidate) {
545        final IceUdpTransportInfo.Candidate candidate = IceUdpTransportInfo.Candidate.fromSdpAttribute(iceCandidate.sdp);
546        Log.d(Config.LOGTAG, "sending candidate: " + iceCandidate.toString());
547        sendTransportInfo(iceCandidate.sdpMid, candidate);
548    }
549
550    @Override
551    public void onConnectionChange(final PeerConnection.PeerConnectionState newState) {
552        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": PeerConnectionState changed to " + newState);
553        updateEndUserState();
554        if (newState == PeerConnection.PeerConnectionState.FAILED) { //TODO guard this in isState(initiated,initated_approved,accepted) otherwise it might fire too late
555            sendSessionTerminate(Reason.CONNECTIVITY_ERROR);
556        }
557    }
558
559    private void updateEndUserState() {
560        xmppConnectionService.notifyJingleRtpConnectionUpdate(id.account, id.with, id.sessionId, getEndUserState());
561    }
562}