JingleRtpConnection.java

  1package eu.siacs.conversations.xmpp.jingle;
  2
  3import android.util.Log;
  4
  5import com.google.common.collect.ImmutableList;
  6import com.google.common.collect.ImmutableMap;
  7
  8import org.webrtc.IceCandidate;
  9
 10import java.util.ArrayDeque;
 11import java.util.Arrays;
 12import java.util.Collection;
 13import java.util.Collections;
 14import java.util.List;
 15import java.util.Map;
 16
 17import eu.siacs.conversations.Config;
 18import eu.siacs.conversations.xml.Element;
 19import eu.siacs.conversations.xml.Namespace;
 20import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
 21import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
 22import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
 23import eu.siacs.conversations.xmpp.stanzas.MessagePacket;
 24import rocks.xmpp.addr.Jid;
 25
 26public class JingleRtpConnection extends AbstractJingleConnection implements WebRTCWrapper.EventCallback {
 27
 28    private static final Map<State, Collection<State>> VALID_TRANSITIONS;
 29
 30    static {
 31        final ImmutableMap.Builder<State, Collection<State>> transitionBuilder = new ImmutableMap.Builder<>();
 32        transitionBuilder.put(State.NULL, ImmutableList.of(State.PROPOSED, State.SESSION_INITIALIZED));
 33        transitionBuilder.put(State.PROPOSED, ImmutableList.of(State.ACCEPTED, State.PROCEED));
 34        transitionBuilder.put(State.PROCEED, ImmutableList.of(State.SESSION_INITIALIZED));
 35        transitionBuilder.put(State.SESSION_INITIALIZED, ImmutableList.of(State.SESSION_ACCEPTED));
 36        VALID_TRANSITIONS = transitionBuilder.build();
 37    }
 38
 39    private final WebRTCWrapper webRTCWrapper = new WebRTCWrapper(this);
 40    private final ArrayDeque<IceCandidate> pendingIceCandidates = new ArrayDeque<>();
 41    private State state = State.NULL;
 42    private RtpContentMap initiatorRtpContentMap;
 43    private RtpContentMap responderRtpContentMap;
 44
 45
 46    public JingleRtpConnection(JingleConnectionManager jingleConnectionManager, Id id, Jid initiator) {
 47        super(jingleConnectionManager, id, initiator);
 48    }
 49
 50    @Override
 51    void deliverPacket(final JinglePacket jinglePacket) {
 52        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": packet delivered to JingleRtpConnection");
 53        switch (jinglePacket.getAction()) {
 54            case SESSION_INITIATE:
 55                receiveSessionInitiate(jinglePacket);
 56                break;
 57            case TRANSPORT_INFO:
 58                receiveTransportInfo(jinglePacket);
 59                break;
 60            case SESSION_ACCEPT:
 61                receiveSessionAccept(jinglePacket);
 62                break;
 63            default:
 64                Log.d(Config.LOGTAG, String.format("%s: received unhandled jingle action %s", id.account.getJid().asBareJid(), jinglePacket.getAction()));
 65                break;
 66        }
 67    }
 68
 69    private void receiveTransportInfo(final JinglePacket jinglePacket) {
 70        if (isInState(State.SESSION_INITIALIZED, State.SESSION_ACCEPTED)) {
 71            final RtpContentMap contentMap;
 72            try {
 73                contentMap = RtpContentMap.of(jinglePacket);
 74            } catch (IllegalArgumentException | NullPointerException e) {
 75                Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": improperly formatted contents", e);
 76                return;
 77            }
 78            final RtpContentMap rtpContentMap = isInitiator() ? this.initiatorRtpContentMap : this.responderRtpContentMap;
 79            final Group originalGroup = rtpContentMap != null ? rtpContentMap.group : null;
 80            final List<String> identificationTags = originalGroup == null ? Collections.emptyList() : originalGroup.getIdentificationTags();
 81            if (identificationTags.size() == 0) {
 82                Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": no identification tags found in initial offer. we won't be able to calculate mLineIndices");
 83            }
 84            for (final Map.Entry<String, RtpContentMap.DescriptionTransport> content : contentMap.contents.entrySet()) {
 85                final String ufrag = content.getValue().transport.getAttribute("ufrag");
 86                for (final IceUdpTransportInfo.Candidate candidate : content.getValue().transport.getCandidates()) {
 87                    final String sdp = candidate.toSdpAttribute(ufrag);
 88                    final String sdpMid = content.getKey();
 89                    final int mLineIndex = identificationTags.indexOf(sdpMid);
 90                    final IceCandidate iceCandidate = new IceCandidate(sdpMid, mLineIndex, sdp);
 91                    Log.d(Config.LOGTAG, "received candidate: " + iceCandidate);
 92                    if (isInState(State.SESSION_ACCEPTED)) {
 93                        this.webRTCWrapper.addIceCandidate(iceCandidate);
 94                    } else {
 95                        this.pendingIceCandidates.push(iceCandidate);
 96                    }
 97                }
 98            }
 99        } else {
100            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received transport info while in state=" + this.state);
101        }
102    }
103
104    private void receiveSessionInitiate(final JinglePacket jinglePacket) {
105        if (isInitiator()) {
106            Log.d(Config.LOGTAG, String.format("%s: received session-initiate even though we were initiating", id.account.getJid().asBareJid()));
107            //TODO respond with out-of-order
108            return;
109        }
110        final RtpContentMap contentMap;
111        try {
112            contentMap = RtpContentMap.of(jinglePacket);
113            contentMap.requireContentDescriptions();
114        } catch (IllegalArgumentException | IllegalStateException | NullPointerException e) {
115            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": improperly formatted contents", e);
116            return;
117        }
118        Log.d(Config.LOGTAG, "processing session-init with " + contentMap.contents.size() + " contents");
119        final State oldState = this.state;
120        if (transition(State.SESSION_INITIALIZED)) {
121            this.initiatorRtpContentMap = contentMap;
122            if (oldState == State.PROCEED) {
123                Log.d(Config.LOGTAG, "automatically accepting");
124                sendSessionAccept();
125            } else {
126                Log.d(Config.LOGTAG, "start ringing");
127                //TODO start ringing
128            }
129        } else {
130            Log.d(Config.LOGTAG, String.format("%s: received session-initiate while in state %s", id.account.getJid().asBareJid(), state));
131        }
132    }
133
134    private void receiveSessionAccept(final JinglePacket jinglePacket) {
135        if (!isInitiator()) {
136            Log.d(Config.LOGTAG, String.format("%s: received session-accept even though we were responding", id.account.getJid().asBareJid()));
137            //TODO respond with out-of-order
138            return;
139        }
140        final RtpContentMap contentMap;
141        try {
142            contentMap = RtpContentMap.of(jinglePacket);
143            contentMap.requireContentDescriptions();
144        } catch (IllegalArgumentException | IllegalStateException | NullPointerException e) {
145            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": improperly formatted contents", e);
146            return;
147        }
148        Log.d(Config.LOGTAG, "processing session-accept with " + contentMap.contents.size() + " contents");
149        if (transition(State.SESSION_ACCEPTED)) {
150            receiveSessionAccept(contentMap);
151        } else {
152            Log.d(Config.LOGTAG, String.format("%s: received session-accept while in state %s", id.account.getJid().asBareJid(), state));
153            //TODO out-of-order
154        }
155    }
156
157    private void receiveSessionAccept(final RtpContentMap contentMap) {
158        this.responderRtpContentMap = contentMap;
159        org.webrtc.SessionDescription answer = new org.webrtc.SessionDescription(
160                org.webrtc.SessionDescription.Type.ANSWER,
161                SessionDescription.of(contentMap).toString()
162        );
163        try {
164            this.webRTCWrapper.setRemoteDescription(answer).get();
165        } catch (Exception e) {
166            Log.d(Config.LOGTAG, "unable to receive session accept", e);
167        }
168    }
169
170    private void sendSessionAccept() {
171        final RtpContentMap rtpContentMap = this.initiatorRtpContentMap;
172        if (rtpContentMap == null) {
173            throw new IllegalStateException("initiator RTP Content Map has not been set");
174        }
175        setupWebRTC();
176        final org.webrtc.SessionDescription offer = new org.webrtc.SessionDescription(
177                org.webrtc.SessionDescription.Type.OFFER,
178                SessionDescription.of(rtpContentMap).toString()
179        );
180        try {
181            this.webRTCWrapper.setRemoteDescription(offer).get();
182            org.webrtc.SessionDescription webRTCSessionDescription = this.webRTCWrapper.createAnswer().get();
183            final SessionDescription sessionDescription = SessionDescription.parse(webRTCSessionDescription.description);
184            final RtpContentMap respondingRtpContentMap = RtpContentMap.of(sessionDescription);
185            sendSessionAccept(respondingRtpContentMap);
186            this.webRTCWrapper.setLocalDescription(webRTCSessionDescription);
187        } catch (Exception e) {
188            Log.d(Config.LOGTAG, "unable to send session accept", e);
189
190        }
191    }
192
193    private void sendSessionAccept(final RtpContentMap rtpContentMap) {
194        this.responderRtpContentMap = rtpContentMap;
195        this.transitionOrThrow(State.SESSION_ACCEPTED);
196        final JinglePacket sessionAccept = rtpContentMap.toJinglePacket(JinglePacket.Action.SESSION_ACCEPT, id.sessionId);
197        Log.d(Config.LOGTAG, sessionAccept.toString());
198        send(sessionAccept);
199    }
200
201    void deliveryMessage(final Jid from, final Element message) {
202        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": delivered message to JingleRtpConnection " + message);
203        switch (message.getName()) {
204            case "propose":
205                receivePropose(from, message);
206                break;
207            case "proceed":
208                receiveProceed(from, message);
209            default:
210                break;
211        }
212    }
213
214    private void receivePropose(final Jid from, final Element propose) {
215        final boolean originatedFromMyself = from.asBareJid().equals(id.account.getJid().asBareJid());
216        //TODO we can use initiator logic here
217        if (originatedFromMyself) {
218            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": saw proposal from mysql. ignoring");
219        } else if (transition(State.PROPOSED)) {
220            //TODO start ringing or something
221            pickUpCall();
222        } else {
223            Log.d(Config.LOGTAG, id.account.getJid() + ": ignoring session proposal because already in " + state);
224        }
225    }
226
227    private void receiveProceed(final Jid from, final Element proceed) {
228        if (from.equals(id.with)) {
229            if (isInitiator()) {
230                if (transition(State.PROCEED)) {
231                    this.sendSessionInitiate();
232                } else {
233                    Log.d(Config.LOGTAG, String.format("%s: ignoring proceed because already in %s", id.account.getJid().asBareJid(), this.state));
234                }
235            } else {
236                Log.d(Config.LOGTAG, String.format("%s: ignoring proceed because we were not initializing", id.account.getJid().asBareJid()));
237            }
238        } else {
239            Log.d(Config.LOGTAG, String.format("%s: ignoring proceed from %s. was expected from %s", id.account.getJid().asBareJid(), from, id.with));
240        }
241    }
242
243    private void sendSessionInitiate() {
244        Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": prepare session-initiate");
245        setupWebRTC();
246        try {
247            org.webrtc.SessionDescription webRTCSessionDescription = this.webRTCWrapper.createOffer().get();
248            final SessionDescription sessionDescription = SessionDescription.parse(webRTCSessionDescription.description);
249            Log.d(Config.LOGTAG, "description: " + webRTCSessionDescription.description);
250            final RtpContentMap rtpContentMap = RtpContentMap.of(sessionDescription);
251            sendSessionInitiate(rtpContentMap);
252            this.webRTCWrapper.setLocalDescription(webRTCSessionDescription).get();
253        } catch (Exception e) {
254            Log.d(Config.LOGTAG, "unable to sendSessionInitiate", e);
255        }
256    }
257
258    private void sendSessionInitiate(RtpContentMap rtpContentMap) {
259        this.initiatorRtpContentMap = rtpContentMap;
260        this.transitionOrThrow(State.SESSION_INITIALIZED);
261        final JinglePacket sessionInitiate = rtpContentMap.toJinglePacket(JinglePacket.Action.SESSION_INITIATE, id.sessionId);
262        Log.d(Config.LOGTAG, sessionInitiate.toString());
263        send(sessionInitiate);
264    }
265
266    private void sendTransportInfo(final String contentName, IceUdpTransportInfo.Candidate candidate) {
267        final RtpContentMap transportInfo;
268        try {
269            final RtpContentMap rtpContentMap = isInitiator() ? this.initiatorRtpContentMap : this.responderRtpContentMap;
270            transportInfo = rtpContentMap.transportInfo(contentName, candidate);
271        } catch (Exception e) {
272            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to prepare transport-info from candidate for content=" + contentName);
273            return;
274        }
275        final JinglePacket jinglePacket = transportInfo.toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
276        Log.d(Config.LOGTAG, jinglePacket.toString());
277        send(jinglePacket);
278    }
279
280    private void send(final JinglePacket jinglePacket) {
281        jinglePacket.setTo(id.with);
282        //TODO track errors
283        xmppConnectionService.sendIqPacket(id.account, jinglePacket, null);
284    }
285
286
287    public void pickUpCall() {
288        switch (this.state) {
289            case PROPOSED:
290                pickupCallFromProposed();
291                break;
292            case SESSION_INITIALIZED:
293                pickupCallFromSessionInitialized();
294                break;
295            default:
296                throw new IllegalStateException("Can not pick up call from " + this.state);
297        }
298    }
299
300    private void setupWebRTC() {
301        this.webRTCWrapper.setup(this.xmppConnectionService);
302        this.webRTCWrapper.initializePeerConnection();
303    }
304
305    private void pickupCallFromProposed() {
306        transitionOrThrow(State.PROCEED);
307        final MessagePacket messagePacket = new MessagePacket();
308        messagePacket.setTo(id.with);
309        //Note that Movim needs 'accept', correct is 'proceed' https://github.com/movim/movim/issues/916
310        messagePacket.addChild("proceed", Namespace.JINGLE_MESSAGE).setAttribute("id", id.sessionId);
311        Log.d(Config.LOGTAG, messagePacket.toString());
312        xmppConnectionService.sendMessagePacket(id.account, messagePacket);
313    }
314
315    private void pickupCallFromSessionInitialized() {
316
317    }
318
319    private synchronized boolean isInState(State... state) {
320        return Arrays.asList(state).contains(this.state);
321    }
322
323    private synchronized boolean transition(final State target) {
324        final Collection<State> validTransitions = VALID_TRANSITIONS.get(this.state);
325        if (validTransitions != null && validTransitions.contains(target)) {
326            this.state = target;
327            Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": transitioned into " + target);
328            return true;
329        } else {
330            return false;
331        }
332    }
333
334    public void transitionOrThrow(final State target) {
335        if (!transition(target)) {
336            throw new IllegalStateException(String.format("Unable to transition from %s to %s", this.state, target));
337        }
338    }
339
340    @Override
341    public void onIceCandidate(final IceCandidate iceCandidate) {
342        final IceUdpTransportInfo.Candidate candidate = IceUdpTransportInfo.Candidate.fromSdpAttribute(iceCandidate.sdp);
343        Log.d(Config.LOGTAG, "sending candidate: " + iceCandidate.toString());
344        sendTransportInfo(iceCandidate.sdpMid, candidate);
345    }
346}