001/**
002 *
003 * Copyright 2003-2006 Jive Software.
004 *
005 * Licensed under the Apache License, Version 2.0 (the "License");
006 * you may not use this file except in compliance with the License.
007 * You may obtain a copy of the License at
008 *
009 *     http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.jivesoftware.smackx.filetransfer;
018
019import java.net.URLConnection;
020import java.util.ArrayList;
021import java.util.Arrays;
022import java.util.Collection;
023import java.util.Collections;
024import java.util.List;
025import java.util.Map;
026import java.util.Random;
027import java.util.concurrent.ConcurrentHashMap;
028
029import org.jivesoftware.smack.AbstractConnectionListener;
030import org.jivesoftware.smack.SmackException.NotConnectedException;
031import org.jivesoftware.smack.XMPPConnection;
032import org.jivesoftware.smack.PacketCollector;
033import org.jivesoftware.smack.XMPPException.XMPPErrorException;
034import org.jivesoftware.smack.packet.IQ;
035import org.jivesoftware.smack.packet.Packet;
036import org.jivesoftware.smack.packet.XMPPError;
037import org.jivesoftware.smackx.bytestreams.ibb.InBandBytestreamManager;
038import org.jivesoftware.smackx.bytestreams.socks5.Socks5BytestreamManager;
039import org.jivesoftware.smackx.disco.ServiceDiscoveryManager;
040import org.jivesoftware.smackx.si.packet.StreamInitiation;
041import org.jivesoftware.smackx.xdata.Form;
042import org.jivesoftware.smackx.xdata.FormField;
043import org.jivesoftware.smackx.xdata.packet.DataForm;
044
045/**
046 * Manages the negotiation of file transfers according to XEP-0096. If a file is
047 * being sent the remote user chooses the type of stream under which the file
048 * will be sent.
049 *
050 * @author Alexander Wenckus
051 * @see <a href="http://xmpp.org/extensions/xep-0096.html">XEP-0096: SI File Transfer</a>
052 */
053public class FileTransferNegotiator {
054
055    // Static
056
057    private static final String[] NAMESPACE = {
058            "http://jabber.org/protocol/si/profile/file-transfer",
059            "http://jabber.org/protocol/si"};
060
061    private static final Map<XMPPConnection, FileTransferNegotiator> transferObject =
062            new ConcurrentHashMap<XMPPConnection, FileTransferNegotiator>();
063
064    private static final String STREAM_INIT_PREFIX = "jsi_";
065
066    protected static final String STREAM_DATA_FIELD_NAME = "stream-method";
067
068    private static final Random randomGenerator = new Random();
069
070    /**
071     * A static variable to use only offer IBB for file transfer. It is generally recommend to only
072     * set this variable to true for testing purposes as IBB is the backup file transfer method
073     * and shouldn't be used as the only transfer method in production systems.
074     */
075    public static boolean IBB_ONLY = (System.getProperty("ibb") != null);//true;
076
077    /**
078     * Returns the file transfer negotiator related to a particular connection.
079     * When this class is requested on a particular connection the file transfer
080     * service is automatically enabled.
081     *
082     * @param connection The connection for which the transfer manager is desired
083     * @return The IMFileTransferManager
084     */
085    public static FileTransferNegotiator getInstanceFor(
086            final XMPPConnection connection) {
087        if (connection == null) {
088            throw new IllegalArgumentException("XMPPConnection cannot be null");
089        }
090        if (!connection.isConnected()) {
091            return null;
092        }
093
094        if (transferObject.containsKey(connection)) {
095            return transferObject.get(connection);
096        }
097        else {
098            FileTransferNegotiator transfer = new FileTransferNegotiator(
099                    connection);
100            setServiceEnabled(connection, true);
101            transferObject.put(connection, transfer);
102            return transfer;
103        }
104    }
105
106    /**
107     * Enable the Jabber services related to file transfer on the particular
108     * connection.
109     *
110     * @param connection The connection on which to enable or disable the services.
111     * @param isEnabled  True to enable, false to disable.
112     */
113    public static void setServiceEnabled(final XMPPConnection connection,
114            final boolean isEnabled) {
115        ServiceDiscoveryManager manager = ServiceDiscoveryManager
116                .getInstanceFor(connection);
117
118        List<String> namespaces = new ArrayList<String>();
119        namespaces.addAll(Arrays.asList(NAMESPACE));
120        namespaces.add(InBandBytestreamManager.NAMESPACE);
121        if (!IBB_ONLY) {
122            namespaces.add(Socks5BytestreamManager.NAMESPACE);
123        }
124
125        for (String namespace : namespaces) {
126            if (isEnabled) {
127                if (!manager.includesFeature(namespace)) {
128                    manager.addFeature(namespace);
129                }
130            } else {
131                manager.removeFeature(namespace);
132            }
133        }
134        
135    }
136
137    /**
138     * Checks to see if all file transfer related services are enabled on the
139     * connection.
140     *
141     * @param connection The connection to check
142     * @return True if all related services are enabled, false if they are not.
143     */
144    public static boolean isServiceEnabled(final XMPPConnection connection) {
145        ServiceDiscoveryManager manager = ServiceDiscoveryManager
146                .getInstanceFor(connection);
147
148        List<String> namespaces = new ArrayList<String>();
149        namespaces.addAll(Arrays.asList(NAMESPACE));
150        namespaces.add(InBandBytestreamManager.NAMESPACE);
151        if (!IBB_ONLY) {
152            namespaces.add(Socks5BytestreamManager.NAMESPACE);
153        }
154
155        for (String namespace : namespaces) {
156            if (!manager.includesFeature(namespace)) {
157                return false;
158            }
159        }
160        return true;
161    }
162
163    /**
164     * A convenience method to create an IQ packet.
165     *
166     * @param ID   The packet ID of the
167     * @param to   To whom the packet is addressed.
168     * @param from From whom the packet is sent.
169     * @param type The IQ type of the packet.
170     * @return The created IQ packet.
171     */
172    public static IQ createIQ(final String ID, final String to,
173            final String from, final IQ.Type type) {
174        IQ iqPacket = new IQ() {
175            public String getChildElementXML() {
176                return null;
177            }
178        };
179        iqPacket.setPacketID(ID);
180        iqPacket.setTo(to);
181        iqPacket.setFrom(from);
182        iqPacket.setType(type);
183
184        return iqPacket;
185    }
186
187    /**
188     * Returns a collection of the supported transfer protocols.
189     *
190     * @return Returns a collection of the supported transfer protocols.
191     */
192    public static Collection<String> getSupportedProtocols() {
193        List<String> protocols = new ArrayList<String>();
194        protocols.add(InBandBytestreamManager.NAMESPACE);
195        if (!IBB_ONLY) {
196            protocols.add(Socks5BytestreamManager.NAMESPACE);
197        }
198        return Collections.unmodifiableList(protocols);
199    }
200
201    // non-static
202
203    private final XMPPConnection connection;
204
205    private final StreamNegotiator byteStreamTransferManager;
206
207    private final StreamNegotiator inbandTransferManager;
208
209    private FileTransferNegotiator(final XMPPConnection connection) {
210        configureConnection(connection);
211
212        this.connection = connection;
213        byteStreamTransferManager = new Socks5TransferNegotiator(connection);
214        inbandTransferManager = new IBBTransferNegotiator(connection);
215    }
216
217    private void configureConnection(final XMPPConnection connection) {
218        connection.addConnectionListener(new AbstractConnectionListener() {
219            @Override
220            public void connectionClosed() {
221                cleanup(connection);
222            }
223
224            @Override
225            public void connectionClosedOnError(Exception e) {
226                cleanup(connection);
227            }
228        });
229    }
230
231    private void cleanup(final XMPPConnection connection) {
232        if (transferObject.remove(connection) != null) {
233            inbandTransferManager.cleanup();
234        }
235    }
236
237    /**
238     * Selects an appropriate stream negotiator after examining the incoming file transfer request.
239     *
240     * @param request The related file transfer request.
241     * @return The file transfer object that handles the transfer
242     * @throws XMPPErrorException If there are either no stream methods contained in the packet, or
243     *                       there is not an appropriate stream method.
244     * @throws NotConnectedException 
245     */
246    public StreamNegotiator selectStreamNegotiator(
247            FileTransferRequest request) throws XMPPErrorException, NotConnectedException {
248        StreamInitiation si = request.getStreamInitiation();
249        FormField streamMethodField = getStreamMethodField(si
250                .getFeatureNegotiationForm());
251
252        if (streamMethodField == null) {
253            String errorMessage = "No stream methods contained in packet.";
254            XMPPError error = new XMPPError(XMPPError.Condition.bad_request, errorMessage);
255            IQ iqPacket = createIQ(si.getPacketID(), si.getFrom(), si.getTo(),
256                    IQ.Type.ERROR);
257            iqPacket.setError(error);
258            connection.sendPacket(iqPacket);
259            throw new XMPPErrorException(errorMessage, error);
260        }
261
262        // select the appropriate protocol
263
264        StreamNegotiator selectedStreamNegotiator;
265        try {
266            selectedStreamNegotiator = getNegotiator(streamMethodField);
267        }
268        catch (XMPPErrorException e) {
269            IQ iqPacket = createIQ(si.getPacketID(), si.getFrom(), si.getTo(),
270                    IQ.Type.ERROR);
271            iqPacket.setError(e.getXMPPError());
272            connection.sendPacket(iqPacket);
273            throw e;
274        }
275
276        // return the appropriate negotiator
277
278        return selectedStreamNegotiator;
279    }
280
281    private FormField getStreamMethodField(DataForm form) {
282        for (FormField field : form.getFields()) {
283            if (field.getVariable().equals(STREAM_DATA_FIELD_NAME)) {
284                return field;
285            }
286        }
287        return null;
288    }
289
290    private StreamNegotiator getNegotiator(final FormField field)
291            throws XMPPErrorException {
292        String variable;
293        boolean isByteStream = false;
294        boolean isIBB = false;
295        for (FormField.Option option : field.getOptions()) {
296            variable = option.getValue();
297            if (variable.equals(Socks5BytestreamManager.NAMESPACE) && !IBB_ONLY) {
298                isByteStream = true;
299            }
300            else if (variable.equals(InBandBytestreamManager.NAMESPACE)) {
301                isIBB = true;
302            }
303        }
304
305        if (!isByteStream && !isIBB) {
306            XMPPError error = new XMPPError(XMPPError.Condition.bad_request,
307                    "No acceptable transfer mechanism");
308            throw new XMPPErrorException(error);
309        }
310
311       //if (isByteStream && isIBB && field.getType().equals(FormField.TYPE_LIST_MULTI)) {
312        if (isByteStream && isIBB) { 
313            return new FaultTolerantNegotiator(connection,
314                    byteStreamTransferManager,
315                    inbandTransferManager);
316        }
317        else if (isByteStream) {
318            return byteStreamTransferManager;
319        }
320        else {
321            return inbandTransferManager;
322        }
323    }
324
325    /**
326     * Returns a new, unique, stream ID to identify a file transfer.
327     *
328     * @return Returns a new, unique, stream ID to identify a file transfer.
329     */
330    public String getNextStreamID() {
331        StringBuilder buffer = new StringBuilder();
332        buffer.append(STREAM_INIT_PREFIX);
333        buffer.append(Math.abs(randomGenerator.nextLong()));
334
335        return buffer.toString();
336    }
337
338    /**
339     * Send a request to another user to send them a file. The other user has
340     * the option of, accepting, rejecting, or not responding to a received file
341     * transfer request.
342     * <p/>
343     * If they accept, the packet will contain the other user's chosen stream
344     * type to send the file across. The two choices this implementation
345     * provides to the other user for file transfer are <a
346     * href="http://www.xmpp.org/extensions/jep-0065.html">SOCKS5 Bytestreams</a>,
347     * which is the preferred method of transfer, and <a
348     * href="http://www.xmpp.org/extensions/jep-0047.html">In-Band Bytestreams</a>,
349     * which is the fallback mechanism.
350     * <p/>
351     * The other user may choose to decline the file request if they do not
352     * desire the file, their client does not support XEP-0096, or if there are
353     * no acceptable means to transfer the file.
354     * <p/>
355     * Finally, if the other user does not respond this method will return null
356     * after the specified timeout.
357     *
358     * @param userID          The userID of the user to whom the file will be sent.
359     * @param streamID        The unique identifier for this file transfer.
360     * @param fileName        The name of this file. Preferably it should include an
361     *                        extension as it is used to determine what type of file it is.
362     * @param size            The size, in bytes, of the file.
363     * @param desc            A description of the file.
364     * @param responseTimeout The amount of time, in milliseconds, to wait for the remote
365     *                        user to respond. If they do not respond in time, this
366     * @return Returns the stream negotiator selected by the peer.
367     * @throws XMPPErrorException Thrown if there is an error negotiating the file transfer.
368     * @throws NotConnectedException 
369     */
370    public StreamNegotiator negotiateOutgoingTransfer(final String userID,
371            final String streamID, final String fileName, final long size,
372            final String desc, int responseTimeout) throws XMPPErrorException, NotConnectedException {
373        StreamInitiation si = new StreamInitiation();
374        si.setSessionID(streamID);
375        si.setMimeType(URLConnection.guessContentTypeFromName(fileName));
376
377        StreamInitiation.File siFile = new StreamInitiation.File(fileName, size);
378        siFile.setDesc(desc);
379        si.setFile(siFile);
380
381        si.setFeatureNegotiationForm(createDefaultInitiationForm());
382
383        si.setFrom(connection.getUser());
384        si.setTo(userID);
385        si.setType(IQ.Type.SET);
386
387        PacketCollector collector = connection.createPacketCollectorAndSend(si);
388        Packet siResponse = collector.nextResult(responseTimeout);
389        collector.cancel();
390
391        if (siResponse instanceof IQ) {
392            IQ iqResponse = (IQ) siResponse;
393            if (iqResponse.getType().equals(IQ.Type.RESULT)) {
394                StreamInitiation response = (StreamInitiation) siResponse;
395                return getOutgoingNegotiator(getStreamMethodField(response
396                        .getFeatureNegotiationForm()));
397
398            }
399            else {
400                throw new XMPPErrorException(iqResponse.getError());
401            }
402        }
403        else {
404            return null;
405        }
406    }
407
408    private StreamNegotiator getOutgoingNegotiator(final FormField field)
409            throws XMPPErrorException {
410        boolean isByteStream = false;
411        boolean isIBB = false;
412        for (String variable : field.getValues()) {
413            if (variable.equals(Socks5BytestreamManager.NAMESPACE) && !IBB_ONLY) {
414                isByteStream = true;
415            }
416            else if (variable.equals(InBandBytestreamManager.NAMESPACE)) {
417                isIBB = true;
418            }
419        }
420
421        if (!isByteStream && !isIBB) {
422            XMPPError error = new XMPPError(XMPPError.Condition.bad_request,
423                    "No acceptable transfer mechanism");
424            throw new XMPPErrorException(error);
425        }
426
427        if (isByteStream && isIBB) {
428            return new FaultTolerantNegotiator(connection,
429                    byteStreamTransferManager, inbandTransferManager);
430        }
431        else if (isByteStream) {
432            return byteStreamTransferManager;
433        }
434        else {
435            return inbandTransferManager;
436        }
437    }
438
439    private DataForm createDefaultInitiationForm() {
440        DataForm form = new DataForm(Form.TYPE_FORM);
441        FormField field = new FormField(STREAM_DATA_FIELD_NAME);
442        field.setType(FormField.TYPE_LIST_SINGLE);
443        if (!IBB_ONLY) {
444            field.addOption(new FormField.Option(Socks5BytestreamManager.NAMESPACE));
445        }
446        field.addOption(new FormField.Option(InBandBytestreamManager.NAMESPACE));
447        form.addField(field);
448        return form;
449    }
450}