JingleConnectionManager.java

  1package eu.siacs.conversations.xmpp.jingle;
  2
  3import android.util.Base64;
  4import android.util.Log;
  5
  6import com.google.common.base.Function;
  7import com.google.common.base.Objects;
  8import com.google.common.base.Preconditions;
  9import com.google.common.collect.Collections2;
 10
 11import org.checkerframework.checker.nullness.compatqual.NullableDecl;
 12
 13import java.lang.ref.WeakReference;
 14import java.security.SecureRandom;
 15import java.util.Collection;
 16import java.util.Collections;
 17import java.util.HashMap;
 18import java.util.List;
 19import java.util.Map;
 20import java.util.Set;
 21import java.util.concurrent.ConcurrentHashMap;
 22
 23import eu.siacs.conversations.Config;
 24import eu.siacs.conversations.entities.Account;
 25import eu.siacs.conversations.entities.Conversation;
 26import eu.siacs.conversations.entities.Conversational;
 27import eu.siacs.conversations.entities.Message;
 28import eu.siacs.conversations.entities.Transferable;
 29import eu.siacs.conversations.services.AbstractConnectionManager;
 30import eu.siacs.conversations.services.XmppConnectionService;
 31import eu.siacs.conversations.xml.Element;
 32import eu.siacs.conversations.xml.Namespace;
 33import eu.siacs.conversations.xmpp.OnIqPacketReceived;
 34import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
 35import eu.siacs.conversations.xmpp.jingle.stanzas.FileTransferDescription;
 36import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription;
 37import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
 38import eu.siacs.conversations.xmpp.jingle.stanzas.Propose;
 39import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
 40import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
 41import eu.siacs.conversations.xmpp.stanzas.IqPacket;
 42import eu.siacs.conversations.xmpp.stanzas.MessagePacket;
 43import rocks.xmpp.addr.Jid;
 44
 45public class JingleConnectionManager extends AbstractConnectionManager {
 46    private final HashMap<RtpSessionProposal, DeviceDiscoveryState> rtpSessionProposals = new HashMap<>();
 47    private final Map<AbstractJingleConnection.Id, AbstractJingleConnection> connections = new ConcurrentHashMap<>();
 48
 49    private HashMap<Jid, JingleCandidate> primaryCandidates = new HashMap<>();
 50
 51    public JingleConnectionManager(XmppConnectionService service) {
 52        super(service);
 53    }
 54
 55    static String nextRandomId() {
 56        final byte[] id = new byte[16];
 57        new SecureRandom().nextBytes(id);
 58        return Base64.encodeToString(id, Base64.NO_WRAP | Base64.NO_PADDING);
 59    }
 60
 61    public void deliverPacket(final Account account, final JinglePacket packet) {
 62        final String sessionId = packet.getSessionId();
 63        if (sessionId == null) {
 64            respondWithJingleError(account, packet, "unknown-session", "item-not-found", "cancel");
 65            return;
 66        }
 67        final AbstractJingleConnection.Id id = AbstractJingleConnection.Id.of(account, packet);
 68        final AbstractJingleConnection existingJingleConnection = connections.get(id);
 69        if (existingJingleConnection != null) {
 70            existingJingleConnection.deliverPacket(packet);
 71        } else if (packet.getAction() == JinglePacket.Action.SESSION_INITIATE) {
 72            final Jid from = packet.getFrom();
 73            final Content content = packet.getJingleContent();
 74            final String descriptionNamespace = content == null ? null : content.getDescriptionNamespace();
 75            final AbstractJingleConnection connection;
 76            if (FileTransferDescription.NAMESPACES.contains(descriptionNamespace)) {
 77                connection = new JingleFileTransferConnection(this, id, from);
 78            } else if (Namespace.JINGLE_APPS_RTP.equals(descriptionNamespace) && !usesTor(account)) {
 79                if (isBusy()) {
 80                    mXmppConnectionService.sendIqPacket(account, packet.generateResponse(IqPacket.TYPE.RESULT), null);
 81                    final JinglePacket sessionTermination = new JinglePacket(JinglePacket.Action.SESSION_TERMINATE, id.sessionId);
 82                    sessionTermination.setTo(id.with);
 83                    sessionTermination.setReason(Reason.BUSY, null);
 84                    mXmppConnectionService.sendIqPacket(account, sessionTermination, null);
 85                    return;
 86                }
 87                connection = new JingleRtpConnection(this, id, from);
 88            } else {
 89                respondWithJingleError(account, packet, "unsupported-info", "feature-not-implemented", "cancel");
 90                return;
 91            }
 92            connections.put(id, connection);
 93            connection.deliverPacket(packet);
 94        } else {
 95            Log.d(Config.LOGTAG, "unable to route jingle packet: " + packet);
 96            respondWithJingleError(account, packet, "unknown-session", "item-not-found", "cancel");
 97        }
 98    }
 99
100    private boolean usesTor(final Account account) {
101        return account.isOnion() || mXmppConnectionService.useTorToConnect();
102    }
103
104    private boolean isBusy() {
105        for (AbstractJingleConnection connection : this.connections.values()) {
106            if (connection instanceof JingleRtpConnection) {
107                return true;
108            }
109        }
110        synchronized (this.rtpSessionProposals) {
111            return this.rtpSessionProposals.containsValue(DeviceDiscoveryState.DISCOVERED) || this.rtpSessionProposals.containsValue(DeviceDiscoveryState.SEARCHING);
112        }
113    }
114
115    public void respondWithJingleError(final Account account, final IqPacket original, String jingleCondition, String condition, String conditionType) {
116        final IqPacket response = original.generateResponse(IqPacket.TYPE.ERROR);
117        final Element error = response.addChild("error");
118        error.setAttribute("type", conditionType);
119        error.addChild(condition, "urn:ietf:params:xml:ns:xmpp-stanzas");
120        error.addChild(jingleCondition, "urn:xmpp:jingle:errors:1");
121        account.getXmppConnection().sendIqPacket(response, null);
122    }
123
124    public void deliverMessage(final Account account, final Jid to, final Jid from, final Element message, String serverMsgId, long timestamp) {
125        Preconditions.checkArgument(Namespace.JINGLE_MESSAGE.equals(message.getNamespace()));
126        final String sessionId = message.getAttribute("id");
127        if (sessionId == null) {
128            return;
129        }
130        if ("accept".equals(message.getName())) {
131            for (AbstractJingleConnection connection : connections.values()) {
132                if (connection instanceof JingleRtpConnection) {
133                    final JingleRtpConnection rtpConnection = (JingleRtpConnection) connection;
134                    final AbstractJingleConnection.Id id = connection.getId();
135                    if (id.account == account && id.sessionId.equals(sessionId)) {
136                        rtpConnection.deliveryMessage(from, message, serverMsgId, timestamp);
137                        return;
138                    }
139                }
140            }
141            return;
142        }
143        final boolean fromSelf = from.asBareJid().equals(account.getJid().asBareJid());
144        final AbstractJingleConnection.Id id;
145        if (fromSelf) {
146            if (to.isFullJid()) {
147                id = AbstractJingleConnection.Id.of(account, to, sessionId);
148            } else {
149                return;
150            }
151        } else {
152            id = AbstractJingleConnection.Id.of(account, from, sessionId);
153        }
154        final AbstractJingleConnection existingJingleConnection = connections.get(id);
155        if (existingJingleConnection != null) {
156            if (existingJingleConnection instanceof JingleRtpConnection) {
157                ((JingleRtpConnection) existingJingleConnection).deliveryMessage(from, message, serverMsgId, timestamp);
158            } else {
159                Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": " + existingJingleConnection.getClass().getName() + " does not support jingle messages");
160            }
161            return;
162        }
163
164        if (fromSelf) {
165            Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": ignore jingle message from self");
166            return;
167        }
168
169        if ("propose".equals(message.getName())) {
170            final Propose propose = Propose.upgrade(message);
171            final List<GenericDescription> descriptions = propose.getDescriptions();
172            final Collection<RtpDescription> rtpDescriptions = Collections2.transform(
173                    Collections2.filter(descriptions, d -> d instanceof RtpDescription),
174                    input -> (RtpDescription) input
175            );
176            if (rtpDescriptions.size() > 0 && rtpDescriptions.size() == descriptions.size() && !usesTor(account)) {
177                final Collection<Media> media = Collections2.transform(rtpDescriptions, RtpDescription::getMedia);
178                if (media.contains(Media.UNKNOWN)) {
179                    Log.d(Config.LOGTAG,account.getJid().asBareJid()+": encountered unknown media in session proposal. "+propose);
180                    return;
181                }
182                if (isBusy()) { //TODO only if no other devices are active
183                    //TODO create
184                    final MessagePacket reject = mXmppConnectionService.getMessageGenerator().sessionReject(from, sessionId);
185                    mXmppConnectionService.sendMessagePacket(account, reject);
186                } else {
187                    final JingleRtpConnection rtpConnection = new JingleRtpConnection(this, id, from);
188                    this.connections.put(id, rtpConnection);
189                    rtpConnection.deliveryMessage(from, message, serverMsgId, timestamp);
190                }
191            } else {
192                Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": unable to react to proposed session with " + rtpDescriptions.size() + " rtp descriptions of " + descriptions.size() + " total descriptions");
193            }
194        } else if ("proceed".equals(message.getName())) {
195            synchronized (rtpSessionProposals) {
196                final RtpSessionProposal proposal = getRtpSessionProposal(account,from.asBareJid(),sessionId);
197                if (proposal != null) {
198                    rtpSessionProposals.remove(proposal);
199                    final JingleRtpConnection rtpConnection = new JingleRtpConnection(this, id, account.getJid());
200                    rtpConnection.setProposedMedia(proposal.media);
201                    this.connections.put(id, rtpConnection);
202                    rtpConnection.transitionOrThrow(AbstractJingleConnection.State.PROPOSED);
203                    rtpConnection.deliveryMessage(from, message, serverMsgId, timestamp);
204                } else {
205                    Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": no rtp session proposal found for " + from + " to deliver proceed");
206                }
207            }
208        } else if ("reject".equals(message.getName())) {
209            final RtpSessionProposal proposal = new RtpSessionProposal(account, from.asBareJid(), sessionId);
210            synchronized (rtpSessionProposals) {
211                if (rtpSessionProposals.remove(proposal) != null) {
212                    writeLogMissedOutgoing(account, proposal.with, proposal.sessionId, serverMsgId, timestamp);
213                    mXmppConnectionService.notifyJingleRtpConnectionUpdate(account, proposal.with, proposal.sessionId, RtpEndUserState.DECLINED_OR_BUSY);
214                } else {
215                    Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": no rtp session proposal found for " + from + " to deliver reject");
216                }
217            }
218        } else {
219            Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": retrieved out of order jingle message");
220        }
221
222    }
223
224    private RtpSessionProposal getRtpSessionProposal(final Account account, Jid from, String sessionId) {
225        for(RtpSessionProposal rtpSessionProposal : rtpSessionProposals.keySet()) {
226            if (rtpSessionProposal.sessionId.equals(sessionId) && rtpSessionProposal.with.equals(from) && rtpSessionProposal.account.getJid().equals(account.getJid())) {
227                return rtpSessionProposal;
228            }
229        }
230        return null;
231    }
232
233    private void writeLogMissedOutgoing(final Account account, Jid with, final String sessionId, String serverMsgId, long timestamp) {
234        final Conversation conversation = mXmppConnectionService.findOrCreateConversation(
235                account,
236                with.asBareJid(),
237                false,
238                false
239        );
240        final Message message = new Message(
241                conversation,
242                Message.STATUS_SEND,
243                Message.TYPE_RTP_SESSION,
244                sessionId
245        );
246        message.setServerMsgId(serverMsgId);
247        message.setTime(timestamp);
248        writeMessage(message);
249    }
250
251    private void writeMessage(final Message message) {
252        final Conversational conversational = message.getConversation();
253        if (conversational instanceof Conversation) {
254            ((Conversation) conversational).add(message);
255            mXmppConnectionService.databaseBackend.createMessage(message);
256            mXmppConnectionService.updateConversationUi();
257        } else {
258            throw new IllegalStateException("Somehow the conversation in a message was a stub");
259        }
260    }
261
262    public void startJingleFileTransfer(final Message message) {
263        Preconditions.checkArgument(message.isFileOrImage(), "Message is not of type file or image");
264        final Transferable old = message.getTransferable();
265        if (old != null) {
266            old.cancel();
267        }
268        final Account account = message.getConversation().getAccount();
269        final AbstractJingleConnection.Id id = AbstractJingleConnection.Id.of(message);
270        final JingleFileTransferConnection connection = new JingleFileTransferConnection(this, id, account.getJid());
271        mXmppConnectionService.markMessage(message, Message.STATUS_WAITING);
272        this.connections.put(id, connection);
273        connection.init(message);
274    }
275
276    void finishConnection(final AbstractJingleConnection connection) {
277        this.connections.remove(connection.getId());
278    }
279
280    void getPrimaryCandidate(final Account account, final boolean initiator, final OnPrimaryCandidateFound listener) {
281        if (Config.DISABLE_PROXY_LOOKUP) {
282            listener.onPrimaryCandidateFound(false, null);
283            return;
284        }
285        if (!this.primaryCandidates.containsKey(account.getJid().asBareJid())) {
286            final Jid proxy = account.getXmppConnection().findDiscoItemByFeature(Namespace.BYTE_STREAMS);
287            if (proxy != null) {
288                IqPacket iq = new IqPacket(IqPacket.TYPE.GET);
289                iq.setTo(proxy);
290                iq.query(Namespace.BYTE_STREAMS);
291                account.getXmppConnection().sendIqPacket(iq, new OnIqPacketReceived() {
292
293                    @Override
294                    public void onIqPacketReceived(Account account, IqPacket packet) {
295                        final Element streamhost = packet.query().findChild("streamhost", Namespace.BYTE_STREAMS);
296                        final String host = streamhost == null ? null : streamhost.getAttribute("host");
297                        final String port = streamhost == null ? null : streamhost.getAttribute("port");
298                        if (host != null && port != null) {
299                            try {
300                                JingleCandidate candidate = new JingleCandidate(nextRandomId(), true);
301                                candidate.setHost(host);
302                                candidate.setPort(Integer.parseInt(port));
303                                candidate.setType(JingleCandidate.TYPE_PROXY);
304                                candidate.setJid(proxy);
305                                candidate.setPriority(655360 + (initiator ? 30 : 0));
306                                primaryCandidates.put(account.getJid().asBareJid(), candidate);
307                                listener.onPrimaryCandidateFound(true, candidate);
308                            } catch (final NumberFormatException e) {
309                                listener.onPrimaryCandidateFound(false, null);
310                            }
311                        } else {
312                            listener.onPrimaryCandidateFound(false, null);
313                        }
314                    }
315                });
316            } else {
317                listener.onPrimaryCandidateFound(false, null);
318            }
319
320        } else {
321            listener.onPrimaryCandidateFound(true,
322                    this.primaryCandidates.get(account.getJid().asBareJid()));
323        }
324    }
325
326    public void retractSessionProposal(final Account account, final Jid with) {
327        synchronized (this.rtpSessionProposals) {
328            RtpSessionProposal matchingProposal = null;
329            for (RtpSessionProposal proposal : this.rtpSessionProposals.keySet()) {
330                if (proposal.account == account && with.asBareJid().equals(proposal.with)) {
331                    matchingProposal = proposal;
332                    break;
333                }
334            }
335            if (matchingProposal != null) {
336                this.rtpSessionProposals.remove(matchingProposal);
337                final MessagePacket messagePacket = mXmppConnectionService.getMessageGenerator().sessionRetract(matchingProposal);
338                writeLogMissedOutgoing(account, matchingProposal.with, matchingProposal.sessionId, null, System.currentTimeMillis());
339                mXmppConnectionService.sendMessagePacket(account, messagePacket);
340
341            }
342        }
343    }
344
345    public void proposeJingleRtpSession(final Account account, final Jid with, final Set<Media> media) {
346        synchronized (this.rtpSessionProposals) {
347            for (Map.Entry<RtpSessionProposal, DeviceDiscoveryState> entry : this.rtpSessionProposals.entrySet()) {
348                RtpSessionProposal proposal = entry.getKey();
349                if (proposal.account == account && with.asBareJid().equals(proposal.with)) {
350                    final DeviceDiscoveryState preexistingState = entry.getValue();
351                    if (preexistingState != null && preexistingState != DeviceDiscoveryState.FAILED) {
352                        mXmppConnectionService.notifyJingleRtpConnectionUpdate(
353                                account,
354                                with,
355                                proposal.sessionId,
356                                preexistingState.toEndUserState()
357                        );
358                        return;
359                    }
360                }
361            }
362            final RtpSessionProposal proposal = RtpSessionProposal.of(account, with.asBareJid(), media);
363            this.rtpSessionProposals.put(proposal, DeviceDiscoveryState.SEARCHING);
364            mXmppConnectionService.notifyJingleRtpConnectionUpdate(
365                    account,
366                    proposal.with,
367                    proposal.sessionId,
368                    RtpEndUserState.FINDING_DEVICE
369            );
370            final MessagePacket messagePacket = mXmppConnectionService.getMessageGenerator().sessionProposal(proposal);
371            Log.d(Config.LOGTAG, messagePacket.toString());
372            mXmppConnectionService.sendMessagePacket(account, messagePacket);
373        }
374    }
375
376    public void deliverIbbPacket(Account account, IqPacket packet) {
377        final String sid;
378        final Element payload;
379        if (packet.hasChild("open", Namespace.IBB)) {
380            payload = packet.findChild("open", Namespace.IBB);
381            sid = payload.getAttribute("sid");
382        } else if (packet.hasChild("data", Namespace.IBB)) {
383            payload = packet.findChild("data", Namespace.IBB);
384            sid = payload.getAttribute("sid");
385        } else if (packet.hasChild("close", Namespace.IBB)) {
386            payload = packet.findChild("close", Namespace.IBB);
387            sid = payload.getAttribute("sid");
388        } else {
389            payload = null;
390            sid = null;
391        }
392        if (sid != null) {
393            for (final AbstractJingleConnection connection : this.connections.values()) {
394                if (connection instanceof JingleFileTransferConnection) {
395                    final JingleFileTransferConnection fileTransfer = (JingleFileTransferConnection) connection;
396                    final JingleTransport transport = fileTransfer.getTransport();
397                    if (transport instanceof JingleInBandTransport) {
398                        final JingleInBandTransport inBandTransport = (JingleInBandTransport) transport;
399                        if (inBandTransport.matches(account, sid)) {
400                            inBandTransport.deliverPayload(packet, payload);
401                        }
402                        return;
403                    }
404                }
405            }
406        }
407        Log.d(Config.LOGTAG, "unable to deliver ibb packet: " + packet.toString());
408        account.getXmppConnection().sendIqPacket(packet.generateResponse(IqPacket.TYPE.ERROR), null);
409    }
410
411    public void notifyRebound() {
412        for (final AbstractJingleConnection connection : this.connections.values()) {
413            connection.notifyRebound();
414        }
415    }
416
417    public WeakReference<JingleRtpConnection> findJingleRtpConnection(Account account, Jid with, String sessionId) {
418        final AbstractJingleConnection.Id id = AbstractJingleConnection.Id.of(account, Jid.ofEscaped(with), sessionId);
419        final AbstractJingleConnection connection = connections.get(id);
420        if (connection instanceof JingleRtpConnection) {
421            return new WeakReference<>((JingleRtpConnection) connection);
422        }
423        return null;
424    }
425
426    public void updateProposedSessionDiscovered(Account account, Jid from, String sessionId, final DeviceDiscoveryState target) {
427        final RtpSessionProposal sessionProposal = new RtpSessionProposal(account, from.asBareJid(), sessionId);
428        synchronized (this.rtpSessionProposals) {
429            final DeviceDiscoveryState currentState = rtpSessionProposals.get(sessionProposal);
430            if (currentState == null) {
431                Log.d(Config.LOGTAG, "unable to find session proposal for session id " + sessionId);
432                return;
433            }
434            if (currentState == DeviceDiscoveryState.DISCOVERED) {
435                Log.d(Config.LOGTAG, "session proposal already at discovered. not going to fall back");
436                return;
437            }
438            this.rtpSessionProposals.put(sessionProposal, target);
439            mXmppConnectionService.notifyJingleRtpConnectionUpdate(account, sessionProposal.with, sessionProposal.sessionId, target.toEndUserState());
440            Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": flagging session " + sessionId + " as " + target);
441        }
442    }
443
444    public void rejectRtpSession(final String sessionId) {
445        for (final AbstractJingleConnection connection : this.connections.values()) {
446            if (connection.getId().sessionId.equals(sessionId)) {
447                if (connection instanceof JingleRtpConnection) {
448                    ((JingleRtpConnection) connection).rejectCall();
449                }
450            }
451        }
452    }
453
454    public void endRtpSession(final String sessionId) {
455        for (final AbstractJingleConnection connection : this.connections.values()) {
456            if (connection.getId().sessionId.equals(sessionId)) {
457                if (connection instanceof JingleRtpConnection) {
458                    ((JingleRtpConnection) connection).endCall();
459                }
460            }
461        }
462    }
463
464    public void failProceed(Account account, final Jid with, String sessionId) {
465        final AbstractJingleConnection.Id id = AbstractJingleConnection.Id.of(account, with, sessionId);
466        final AbstractJingleConnection existingJingleConnection = connections.get(id);
467        if (existingJingleConnection instanceof JingleRtpConnection) {
468            ((JingleRtpConnection) existingJingleConnection).deliverFailedProceed();
469        }
470    }
471
472    public enum DeviceDiscoveryState {
473        SEARCHING, DISCOVERED, FAILED;
474
475        public RtpEndUserState toEndUserState() {
476            switch (this) {
477                case SEARCHING:
478                    return RtpEndUserState.FINDING_DEVICE;
479                case DISCOVERED:
480                    return RtpEndUserState.RINGING;
481                default:
482                    return RtpEndUserState.CONNECTIVITY_ERROR;
483            }
484        }
485    }
486
487    public static class RtpSessionProposal {
488        public final Jid with;
489        public final String sessionId;
490        private final Account account;
491        public final Set<Media> media;
492
493        private RtpSessionProposal(Account account, Jid with, String sessionId) {
494            this(account,with,sessionId, Collections.emptySet());
495        }
496
497        private RtpSessionProposal(Account account, Jid with, String sessionId, Set<Media> media) {
498            this.account = account;
499            this.with = with;
500            this.sessionId = sessionId;
501            this.media = media;
502        }
503
504        public static RtpSessionProposal of(Account account, Jid with, Set<Media> media) {
505            return new RtpSessionProposal(account, with, nextRandomId(), media);
506        }
507
508        @Override
509        public boolean equals(Object o) {
510            if (this == o) return true;
511            if (o == null || getClass() != o.getClass()) return false;
512            RtpSessionProposal proposal = (RtpSessionProposal) o;
513            return Objects.equal(account.getJid(), proposal.account.getJid()) &&
514                    Objects.equal(with, proposal.with) &&
515                    Objects.equal(sessionId, proposal.sessionId);
516        }
517
518        @Override
519        public int hashCode() {
520            return Objects.hashCode(account.getJid(), with, sessionId);
521        }
522    }
523}