001/**
002 *
003 * Copyright 2009 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 */
017
018package org.jivesoftware.smack.bosh;
019
020import java.io.IOException;
021import java.io.PipedReader;
022import java.io.PipedWriter;
023import java.io.Writer;
024import java.util.Map;
025import java.util.logging.Level;
026import java.util.logging.Logger;
027
028import org.jivesoftware.smack.AbstractXMPPConnection;
029import org.jivesoftware.smack.SmackException;
030import org.jivesoftware.smack.SmackException.GenericConnectionException;
031import org.jivesoftware.smack.SmackException.NotConnectedException;
032import org.jivesoftware.smack.SmackException.SmackWrappedException;
033import org.jivesoftware.smack.XMPPConnection;
034import org.jivesoftware.smack.XMPPException;
035import org.jivesoftware.smack.XMPPException.StreamErrorException;
036import org.jivesoftware.smack.packet.Element;
037import org.jivesoftware.smack.packet.IQ;
038import org.jivesoftware.smack.packet.Message;
039import org.jivesoftware.smack.packet.Nonza;
040import org.jivesoftware.smack.packet.Presence;
041import org.jivesoftware.smack.packet.Stanza;
042import org.jivesoftware.smack.packet.StanzaError;
043import org.jivesoftware.smack.util.CloseableUtil;
044import org.jivesoftware.smack.util.PacketParserUtils;
045import org.jivesoftware.smack.xml.XmlPullParser;
046
047import org.igniterealtime.jbosh.AbstractBody;
048import org.igniterealtime.jbosh.BOSHClient;
049import org.igniterealtime.jbosh.BOSHClientConfig;
050import org.igniterealtime.jbosh.BOSHClientConnEvent;
051import org.igniterealtime.jbosh.BOSHClientConnListener;
052import org.igniterealtime.jbosh.BOSHClientRequestListener;
053import org.igniterealtime.jbosh.BOSHClientResponseListener;
054import org.igniterealtime.jbosh.BOSHException;
055import org.igniterealtime.jbosh.BOSHMessageEvent;
056import org.igniterealtime.jbosh.BodyQName;
057import org.igniterealtime.jbosh.ComposableBody;
058import org.jxmpp.jid.DomainBareJid;
059import org.jxmpp.jid.parts.Resourcepart;
060
061/**
062 * Creates a connection to an XMPP server via HTTP binding.
063 * This is specified in the XEP-0206: XMPP Over BOSH.
064 *
065 * @see XMPPConnection
066 * @author Guenther Niess
067 */
068public class XMPPBOSHConnection extends AbstractXMPPConnection {
069    private static final Logger LOGGER = Logger.getLogger(XMPPBOSHConnection.class.getName());
070
071    /**
072     * The XMPP Over Bosh namespace.
073     */
074    public static final String XMPP_BOSH_NS = "urn:xmpp:xbosh";
075
076    /**
077     * The BOSH namespace from XEP-0124.
078     */
079    public static final String BOSH_URI = "http://jabber.org/protocol/httpbind";
080
081    /**
082     * The used BOSH client from the jbosh library.
083     */
084    private BOSHClient client;
085
086    /**
087     * Holds the initial configuration used while creating the connection.
088     */
089    @SuppressWarnings("HidingField")
090    private final BOSHConfiguration config;
091
092    // Some flags which provides some info about the current state.
093    private boolean isFirstInitialization = true;
094    private boolean done = false;
095
096    // The readerPipe and consumer thread are used for the debugger.
097    private PipedWriter readerPipe;
098    private Thread readerConsumer;
099
100    /**
101     * The session ID for the BOSH session with the connection manager.
102     */
103    protected String sessionID = null;
104
105    private boolean notified;
106
107    /**
108     * Create a HTTP Binding connection to an XMPP server.
109     *
110     * @param username the username to use.
111     * @param password the password to use.
112     * @param https true if you want to use SSL
113     *             (e.g. false for http://domain.lt:7070/http-bind).
114     * @param host the hostname or IP address of the connection manager
115     *             (e.g. domain.lt for http://domain.lt:7070/http-bind).
116     * @param port the port of the connection manager
117     *             (e.g. 7070 for http://domain.lt:7070/http-bind).
118     * @param filePath the file which is described by the URL
119     *             (e.g. /http-bind for http://domain.lt:7070/http-bind).
120     * @param xmppServiceDomain the XMPP service name
121     *             (e.g. domain.lt for the user alice@domain.lt)
122     */
123    public XMPPBOSHConnection(String username, String password, boolean https, String host, int port, String filePath, DomainBareJid xmppServiceDomain) {
124        this(BOSHConfiguration.builder().setUseHttps(https).setHost(host)
125                .setPort(port).setFile(filePath).setXmppDomain(xmppServiceDomain)
126                .setUsernameAndPassword(username, password).build());
127    }
128
129    /**
130     * Create a HTTP Binding connection to an XMPP server.
131     *
132     * @param config The configuration which is used for this connection.
133     */
134    public XMPPBOSHConnection(BOSHConfiguration config) {
135        super(config);
136        this.config = config;
137    }
138
139    @SuppressWarnings("deprecation")
140    @Override
141    protected void connectInternal() throws SmackException, InterruptedException {
142        done = false;
143        notified = false;
144        try {
145            // Ensure a clean starting state
146            if (client != null) {
147                client.close();
148                client = null;
149            }
150            sessionID = null;
151
152            // Initialize BOSH client
153            BOSHClientConfig.Builder cfgBuilder = BOSHClientConfig.Builder
154                    .create(config.getURI(), config.getXMPPServiceDomain().toString());
155            if (config.isProxyEnabled()) {
156                cfgBuilder.setProxy(config.getProxyAddress(), config.getProxyPort());
157            }
158
159            cfgBuilder.setCompressionEnabled(config.isCompressionEnabled());
160
161            for (Map.Entry<String, String> h : config.getHttpHeaders().entrySet()) {
162                cfgBuilder.addHttpHeader(h.getKey(), h.getValue());
163            }
164
165            client = BOSHClient.create(cfgBuilder.build());
166
167            client.addBOSHClientConnListener(new BOSHConnectionListener());
168            client.addBOSHClientResponseListener(new BOSHPacketReader());
169
170            // Initialize the debugger
171            if (debugger != null) {
172                initDebugger();
173            }
174
175            // Send the session creation request
176            client.send(ComposableBody.builder()
177                    .setNamespaceDefinition("xmpp", XMPP_BOSH_NS)
178                    .setAttribute(BodyQName.createWithPrefix(XMPP_BOSH_NS, "version", "xmpp"), "1.0")
179                    .build());
180        } catch (Exception e) {
181            throw new GenericConnectionException(e);
182        }
183
184        // Wait for the response from the server
185        synchronized (this) {
186            if (!connected) {
187                final long deadline = System.currentTimeMillis() + getReplyTimeout();
188                while (!notified) {
189                    final long now = System.currentTimeMillis();
190                    if (now >= deadline) break;
191                    wait(deadline - now);
192                }
193            }
194        }
195
196        // If there is no feedback, throw an remote server timeout error
197        if (!connected && !done) {
198            done = true;
199            String errorMessage = "Timeout reached for the connection to "
200                    + getHost() + ":" + getPort() + ".";
201            throw new SmackException.SmackMessageException(errorMessage);
202        }
203    }
204
205    @Override
206    public boolean isSecureConnection() {
207        // TODO: Implement SSL usage
208        return false;
209    }
210
211    @Override
212    public boolean isUsingCompression() {
213        // TODO: Implement compression
214        return false;
215    }
216
217    @Override
218    protected void loginInternal(String username, String password, Resourcepart resource) throws XMPPException,
219                    SmackException, IOException, InterruptedException {
220        // Authenticate using SASL
221        authenticate(username, password, config.getAuthzid(), null);
222
223        bindResourceAndEstablishSession(resource);
224
225        afterSuccessfulLogin(false);
226    }
227
228    @Override
229    public void sendNonza(Nonza element) throws NotConnectedException {
230        if (done) {
231            throw new NotConnectedException();
232        }
233        sendElement(element);
234    }
235
236    @Override
237    protected void sendStanzaInternal(Stanza packet) throws NotConnectedException {
238        sendElement(packet);
239    }
240
241    private void sendElement(Element element) {
242        try {
243            send(ComposableBody.builder().setPayloadXML(element.toXML(BOSH_URI).toString()).build());
244            if (element instanceof Stanza) {
245                firePacketSendingListeners((Stanza) element);
246            }
247        }
248        catch (BOSHException e) {
249            LOGGER.log(Level.SEVERE, "BOSHException in sendStanzaInternal", e);
250        }
251    }
252
253    /**
254     * Closes the connection by setting presence to unavailable and closing the
255     * HTTP client. The shutdown logic will be used during a planned disconnection or when
256     * dealing with an unexpected disconnection. Unlike {@link #disconnect()} the connection's
257     * BOSH stanza reader will not be removed; thus connection's state is kept.
258     *
259     */
260    @Override
261    protected void shutdown() {
262
263        if (client != null) {
264            try {
265                client.disconnect();
266            } catch (Exception e) {
267                LOGGER.log(Level.WARNING, "shutdown", e);
268            }
269            client = null;
270        }
271
272        instantShutdown();
273    }
274
275    @Override
276    public void instantShutdown() {
277        setWasAuthenticated();
278        sessionID = null;
279        done = true;
280        authenticated = false;
281        connected = false;
282        isFirstInitialization = false;
283
284        // Close down the readers and writers.
285        CloseableUtil.maybeClose(readerPipe, LOGGER);
286        CloseableUtil.maybeClose(reader, LOGGER);
287        CloseableUtil.maybeClose(writer, LOGGER);
288
289        readerPipe = null;
290        reader = null;
291        writer = null;
292        readerConsumer = null;
293    }
294
295    /**
296     * Send a HTTP request to the connection manager with the provided body element.
297     *
298     * @param body the body which will be sent.
299     * @throws BOSHException if an BOSH (Bidirectional-streams Over Synchronous HTTP, XEP-0124) related error occurs
300     */
301    protected void send(ComposableBody body) throws BOSHException {
302        if (!connected) {
303            throw new IllegalStateException("Not connected to a server!");
304        }
305        if (body == null) {
306            throw new NullPointerException("Body mustn't be null!");
307        }
308        if (sessionID != null) {
309            body = body.rebuild().setAttribute(
310                    BodyQName.create(BOSH_URI, "sid"), sessionID).build();
311        }
312        client.send(body);
313    }
314
315    /**
316     * Initialize the SmackDebugger which allows to log and debug XML traffic.
317     */
318    @Override
319    protected void initDebugger() {
320        // TODO: Maybe we want to extend the SmackDebugger for simplification
321        //       and a performance boost.
322
323        // Initialize a empty writer which discards all data.
324        writer = new Writer() {
325            @Override
326            public void write(char[] cbuf, int off, int len) {
327                /* ignore */ }
328
329            @Override
330            public void close() {
331                /* ignore */ }
332
333            @Override
334            public void flush() {
335                /* ignore */ }
336        };
337
338        // Initialize a pipe for received raw data.
339        try {
340            readerPipe = new PipedWriter();
341            reader = new PipedReader(readerPipe);
342        }
343        catch (IOException e) {
344            // Ignore
345        }
346
347        // Call the method from the parent class which initializes the debugger.
348        super.initDebugger();
349
350        // Add listeners for the received and sent raw data.
351        client.addBOSHClientResponseListener(new BOSHClientResponseListener() {
352            @Override
353            public void responseReceived(BOSHMessageEvent event) {
354                if (event.getBody() != null) {
355                    try {
356                        readerPipe.write(event.getBody().toXML());
357                        readerPipe.flush();
358                    } catch (Exception e) {
359                        // Ignore
360                    }
361                }
362            }
363        });
364        client.addBOSHClientRequestListener(new BOSHClientRequestListener() {
365            @Override
366            public void requestSent(BOSHMessageEvent event) {
367                if (event.getBody() != null) {
368                    try {
369                        writer.write(event.getBody().toXML());
370                    } catch (Exception e) {
371                        // Ignore
372                    }
373                }
374            }
375        });
376
377        // Create and start a thread which discards all read data.
378        readerConsumer = new Thread() {
379            private Thread thread = this;
380            private int bufferLength = 1024;
381
382            @Override
383            public void run() {
384                try {
385                    char[] cbuf = new char[bufferLength];
386                    while (readerConsumer == thread && !done) {
387                        reader.read(cbuf, 0, bufferLength);
388                    }
389                } catch (IOException e) {
390                    // Ignore
391                }
392            }
393        };
394        readerConsumer.setDaemon(true);
395        readerConsumer.start();
396    }
397
398    @Override
399    protected void afterSaslAuthenticationSuccess()
400                    throws NotConnectedException, InterruptedException, SmackWrappedException {
401        // XMPP over BOSH is unusual when it comes to SASL authentication: Instead of sending a new stream open, it
402        // requires a special XML element ot be send after successful SASL authentication.
403        // See XEP-0206 ยง 5., especially the following is example 5 of XEP-0206.
404        ComposableBody composeableBody = ComposableBody.builder().setNamespaceDefinition("xmpp",
405                        XMPPBOSHConnection.XMPP_BOSH_NS).setAttribute(
406                        BodyQName.createWithPrefix(XMPPBOSHConnection.XMPP_BOSH_NS, "restart",
407                                        "xmpp"), "true").setAttribute(
408                        BodyQName.create(XMPPBOSHConnection.BOSH_URI, "to"), getXMPPServiceDomain().toString()).build();
409
410        try {
411            send(composeableBody);
412        } catch (BOSHException e) {
413            // jbosh's exception API does not really match the one of Smack.
414            throw new SmackException.SmackWrappedException(e);
415        }
416    }
417
418    /**
419     * A listener class which listen for a successfully established connection
420     * and connection errors and notifies the BOSHConnection.
421     *
422     * @author Guenther Niess
423     */
424    private class BOSHConnectionListener implements BOSHClientConnListener {
425
426        /**
427         * Notify the BOSHConnection about connection state changes.
428         * Process the connection listeners and try to login if the
429         * connection was formerly authenticated and is now reconnected.
430         */
431        @Override
432        public void connectionEvent(BOSHClientConnEvent connEvent) {
433            try {
434                if (connEvent.isConnected()) {
435                    connected = true;
436                    if (isFirstInitialization) {
437                        isFirstInitialization = false;
438                    }
439                    else {
440                            if (wasAuthenticated) {
441                                try {
442                                    login();
443                                }
444                                catch (Exception e) {
445                                    throw new RuntimeException(e);
446                                }
447                            }
448                    }
449                }
450                else {
451                    if (connEvent.isError()) {
452                        // TODO Check why jbosh's getCause returns Throwable here. This is very
453                        // unusual and should be avoided if possible
454                        Throwable cause = connEvent.getCause();
455                        Exception e;
456                        if (cause instanceof Exception) {
457                            e = (Exception) cause;
458                        } else {
459                            e = new Exception(cause);
460                        }
461                        notifyConnectionError(e);
462                    }
463                    connected = false;
464                }
465            }
466            finally {
467                notified = true;
468                synchronized (XMPPBOSHConnection.this) {
469                    XMPPBOSHConnection.this.notifyAll();
470                }
471            }
472        }
473    }
474
475    /**
476     * Listens for XML traffic from the BOSH connection manager and parses it into
477     * stanza objects.
478     *
479     * @author Guenther Niess
480     */
481    private class BOSHPacketReader implements BOSHClientResponseListener {
482
483        /**
484         * Parse the received packets and notify the corresponding connection.
485         *
486         * @param event the BOSH client response which includes the received packet.
487         */
488        @Override
489        public void responseReceived(BOSHMessageEvent event) {
490            AbstractBody body = event.getBody();
491            if (body != null) {
492                try {
493                    if (sessionID == null) {
494                        sessionID = body.getAttribute(BodyQName.create(XMPPBOSHConnection.BOSH_URI, "sid"));
495                    }
496                    if (streamId == null) {
497                        streamId = body.getAttribute(BodyQName.create(XMPPBOSHConnection.BOSH_URI, "authid"));
498                    }
499                    final XmlPullParser parser = PacketParserUtils.getParserFor(body.toXML());
500
501                    XmlPullParser.Event eventType = parser.getEventType();
502                    do {
503                        eventType = parser.next();
504                        switch (eventType) {
505                        case START_ELEMENT:
506                            String name = parser.getName();
507                            switch (name) {
508                            case Message.ELEMENT:
509                            case IQ.IQ_ELEMENT:
510                            case Presence.ELEMENT:
511                                parseAndProcessStanza(parser);
512                                break;
513                            case "features":
514                                parseFeatures(parser);
515                                break;
516                            case "error":
517                                // Some BOSH error isn't stream error.
518                                if ("urn:ietf:params:xml:ns:xmpp-streams".equals(parser.getNamespace(null))) {
519                                    throw new StreamErrorException(PacketParserUtils.parseStreamError(parser));
520                                } else {
521                                    StanzaError stanzaError = PacketParserUtils.parseError(parser);
522                                    throw new XMPPException.XMPPErrorException(null, stanzaError);
523                                }
524                            default:
525                                parseAndProcessNonza(parser);
526                                break;
527                            }
528                            break;
529                        default:
530                            // Catch all for incomplete switch (MissingCasesInEnumSwitch) statement.
531                            break;
532                        }
533                    }
534                    while (eventType != XmlPullParser.Event.END_DOCUMENT);
535                }
536                catch (Exception e) {
537                    if (isConnected()) {
538                        notifyConnectionError(e);
539                    }
540                }
541            }
542        }
543    }
544}