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