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