JingleRtpConnection.java

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