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