001/**
002 *
003 * Copyright 2012-2017 Florian Schmaus
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.ping;
018
019import java.util.Map;
020import java.util.Set;
021import java.util.WeakHashMap;
022import java.util.concurrent.CopyOnWriteArraySet;
023import java.util.concurrent.Executors;
024import java.util.concurrent.ScheduledExecutorService;
025import java.util.concurrent.ScheduledFuture;
026import java.util.concurrent.TimeUnit;
027import java.util.logging.Level;
028import java.util.logging.Logger;
029
030import org.jivesoftware.smack.AbstractConnectionClosedListener;
031import org.jivesoftware.smack.ConnectionCreationListener;
032import org.jivesoftware.smack.Manager;
033import org.jivesoftware.smack.SmackException;
034import org.jivesoftware.smack.SmackException.NoResponseException;
035import org.jivesoftware.smack.SmackException.NotConnectedException;
036import org.jivesoftware.smack.SmackException.NotLoggedInException;
037import org.jivesoftware.smack.SmackFuture;
038import org.jivesoftware.smack.SmackFuture.InternalSmackFuture;
039import org.jivesoftware.smack.XMPPConnection;
040import org.jivesoftware.smack.XMPPConnectionRegistry;
041import org.jivesoftware.smack.XMPPException.XMPPErrorException;
042import org.jivesoftware.smack.iqrequest.AbstractIqRequestHandler;
043import org.jivesoftware.smack.iqrequest.IQRequestHandler.Mode;
044import org.jivesoftware.smack.packet.IQ;
045import org.jivesoftware.smack.packet.IQ.Type;
046import org.jivesoftware.smack.packet.Stanza;
047import org.jivesoftware.smack.packet.XMPPError;
048import org.jivesoftware.smack.util.SmackExecutorThreadFactory;
049
050import org.jivesoftware.smackx.disco.ServiceDiscoveryManager;
051import org.jivesoftware.smackx.ping.packet.Ping;
052
053import org.jxmpp.jid.Jid;
054
055/**
056 * Implements the XMPP Ping as defined by XEP-0199. The XMPP Ping protocol allows one entity to
057 * ping any other entity by simply sending a ping to the appropriate JID. PingManger also
058 * periodically sends XMPP pings to the server to avoid NAT timeouts and to test
059 * the connection status.
060 * <p>
061 * The default server ping interval is 30 minutes and can be modified with
062 * {@link #setDefaultPingInterval(int)} and {@link #setPingInterval(int)}.
063 * </p>
064 * 
065 * @author Florian Schmaus
066 * @see <a href="http://www.xmpp.org/extensions/xep-0199.html">XEP-0199:XMPP Ping</a>
067 */
068public final class PingManager extends Manager {
069    private static final Logger LOGGER = Logger.getLogger(PingManager.class.getName());
070
071    private static final Map<XMPPConnection, PingManager> INSTANCES = new WeakHashMap<>();
072
073    static {
074        XMPPConnectionRegistry.addConnectionCreationListener(new ConnectionCreationListener() {
075            @Override
076            public void connectionCreated(XMPPConnection connection) {
077                getInstanceFor(connection);
078            }
079        });
080    }
081
082    /**
083     * Retrieves a {@link PingManager} for the specified {@link XMPPConnection}, creating one if it doesn't already
084     * exist.
085     * 
086     * @param connection
087     * The connection the manager is attached to.
088     * @return The new or existing manager.
089     */
090    public synchronized static PingManager getInstanceFor(XMPPConnection connection) {
091        PingManager pingManager = INSTANCES.get(connection);
092        if (pingManager == null) {
093            pingManager = new PingManager(connection);
094            INSTANCES.put(connection, pingManager);
095        }
096        return pingManager;
097    }
098
099    /**
100     * The default ping interval in seconds used by new PingManager instances. The default is 30 minutes.
101     */
102    private static int defaultPingInterval = 60 * 30;
103
104    /**
105     * Set the default ping interval which will be used for new connections.
106     *
107     * @param interval the interval in seconds
108     */
109    public static void setDefaultPingInterval(int interval) {
110        defaultPingInterval = interval;
111    }
112
113    private final Set<PingFailedListener> pingFailedListeners = new CopyOnWriteArraySet<>();
114
115    private final ScheduledExecutorService executorService;
116
117    /**
118     * The interval in seconds between pings are send to the users server.
119     */
120    private int pingInterval = defaultPingInterval;
121
122    private ScheduledFuture<?> nextAutomaticPing;
123
124    private PingManager(XMPPConnection connection) {
125        super(connection);
126        executorService = Executors.newSingleThreadScheduledExecutor(
127                        new SmackExecutorThreadFactory(connection, "Ping"));
128        ServiceDiscoveryManager sdm = ServiceDiscoveryManager.getInstanceFor(connection);
129        sdm.addFeature(Ping.NAMESPACE);
130
131        connection.registerIQRequestHandler(new AbstractIqRequestHandler(Ping.ELEMENT, Ping.NAMESPACE, Type.get, Mode.async) {
132            @Override
133            public IQ handleIQRequest(IQ iqRequest) {
134                Ping ping = (Ping) iqRequest;
135                return ping.getPong();
136            }
137        });
138        connection.addConnectionListener(new AbstractConnectionClosedListener() {
139            @Override
140            public void authenticated(XMPPConnection connection, boolean resumed) {
141                maybeSchedulePingServerTask();
142            }
143            @Override
144            public void connectionTerminated() {
145                maybeStopPingServerTask();
146            }
147        });
148        maybeSchedulePingServerTask();
149    }
150
151    private boolean isValidErrorPong(Jid destinationJid, XMPPErrorException xmppErrorException) {
152        // If it is an error error response and the destination was our own service, then this must mean that the
153        // service responded, i.e. is up and pingable.
154        if (destinationJid.equals(connection().getServiceName())) {
155            return true;
156        }
157
158        final XMPPError xmppError = xmppErrorException.getXMPPError();
159
160        // We may received an error response from an intermediate service returning an error like
161        // 'remote-server-not-found' or 'remote-server-timeout' to us (which would fake the 'from' address,
162        // see RFC 6120 § 8.3.1 2.). Or the recipient could became unavailable.
163
164        // Sticking with the current rules of RFC 6120/6121, it is undecidable at this point whether we received an
165        // error response from the pinged entity or not. This is because a service-unavailable error condition is
166        // *required* (as per the RFCs) to be send back in both relevant cases:
167        // 1. When the receiving entity is unaware of the IQ request type. RFC 6120 § 8.4.:
168        //    "If an intended recipient receives an IQ stanza of type "get" or
169        //    "set" containing a child element qualified by a namespace it does
170        //    not understand, then the entity MUST return an IQ stanza of type
171        //    "error" with an error condition of <service-unavailable/>.
172        //  2. When the receiving resource is not available. RFC 6121 § 8.5.3.2.3.
173
174        // Some clients don't obey the first rule and instead send back a feature-not-implement condition with type 'cancel',
175        // which allows us to consider this response as valid "error response" pong.
176        XMPPError.Type type = xmppError.getType();
177        XMPPError.Condition condition = xmppError.getCondition();
178        return type == XMPPError.Type.CANCEL && condition == XMPPError.Condition.feature_not_implemented;
179    }
180
181    public SmackFuture<Boolean> pingAsync(Jid jid) {
182        return pingAsync(jid, connection().getReplyTimeout());
183    }
184
185    public SmackFuture<Boolean> pingAsync(final Jid jid, long pongTimeout) {
186        final InternalSmackFuture<Boolean> future = new InternalSmackFuture<Boolean>() {
187            @Override
188            public void handleStanza(Stanza packet) throws NotConnectedException, InterruptedException {
189                setResult(true);
190            }
191            @Override
192            public boolean isNonFatalException(Exception exception) {
193                if (exception instanceof XMPPErrorException) {
194                    XMPPErrorException xmppErrorException = (XMPPErrorException) exception;
195                    if (isValidErrorPong(jid, xmppErrorException)) {
196                        setResult(true);
197                        return true;
198                    }
199                }
200                return false;
201            }
202        };
203
204        Ping ping = new Ping(jid);
205        try {
206            XMPPConnection connection = getAuthenticatedConnectionOrThrow();
207            connection.sendIqWithResponseCallback(ping, future, future, pongTimeout);
208        }
209        catch (NotLoggedInException | NotConnectedException | InterruptedException e) {
210            future.processException(e);
211        }
212
213        return future;
214    }
215
216    /**
217     * Pings the given jid. This method will return false if an error occurs.  The exception 
218     * to this, is a server ping, which will always return true if the server is reachable, 
219     * event if there is an error on the ping itself (i.e. ping not supported).
220     * <p>
221     * Use {@link #isPingSupported(Jid)} to determine if XMPP Ping is supported 
222     * by the entity.
223     * 
224     * @param jid The id of the entity the ping is being sent to
225     * @param pingTimeout The time to wait for a reply in milliseconds
226     * @return true if a reply was received from the entity, false otherwise.
227     * @throws NoResponseException if there was no response from the jid.
228     * @throws NotConnectedException 
229     * @throws InterruptedException 
230     */
231    public boolean ping(Jid jid, long pingTimeout) throws NotConnectedException, NoResponseException, InterruptedException {
232        final XMPPConnection connection = connection();
233        // Packet collector for IQs needs an connection that was at least authenticated once,
234        // otherwise the client JID will be null causing an NPE
235        if (!connection.isAuthenticated()) {
236            throw new NotConnectedException();
237        }
238        Ping ping = new Ping(jid);
239        try {
240            connection.createStanzaCollectorAndSend(ping).nextResultOrThrow(pingTimeout);
241        }
242        catch (XMPPErrorException e) {
243            return isValidErrorPong(jid, e);
244        }
245        return true;
246    }
247
248    /**
249     * Same as calling {@link #ping(Jid, long)} with the defaultpacket reply 
250     * timeout.
251     * 
252     * @param jid The id of the entity the ping is being sent to
253     * @return true if a reply was received from the entity, false otherwise.
254     * @throws NotConnectedException
255     * @throws NoResponseException if there was no response from the jid.
256     * @throws InterruptedException 
257     */
258    public boolean ping(Jid jid) throws NotConnectedException, NoResponseException, InterruptedException {
259        return ping(jid, connection().getReplyTimeout());
260    }
261
262    /**
263     * Query the specified entity to see if it supports the Ping protocol (XEP-0199).
264     * 
265     * @param jid The id of the entity the query is being sent to
266     * @return true if it supports ping, false otherwise.
267     * @throws XMPPErrorException An XMPP related error occurred during the request 
268     * @throws NoResponseException if there was no response from the jid.
269     * @throws NotConnectedException 
270     * @throws InterruptedException 
271     */
272    public boolean isPingSupported(Jid jid) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException  {
273        return ServiceDiscoveryManager.getInstanceFor(connection()).supportsFeature(jid, Ping.NAMESPACE);
274    }
275
276    /**
277     * Pings the server. This method will return true if the server is reachable.  It
278     * is the equivalent of calling <code>ping</code> with the XMPP domain.
279     * <p>
280     * Unlike the {@link #ping(Jid)} case, this method will return true even if 
281     * {@link #isPingSupported(Jid)} is false.
282     * 
283     * @return true if a reply was received from the server, false otherwise.
284     * @throws NotConnectedException
285     * @throws InterruptedException 
286     */
287    public boolean pingMyServer() throws NotConnectedException, InterruptedException {
288        return pingMyServer(true);
289    }
290
291    /**
292     * Pings the server. This method will return true if the server is reachable.  It
293     * is the equivalent of calling <code>ping</code> with the XMPP domain.
294     * <p>
295     * Unlike the {@link #ping(Jid)} case, this method will return true even if
296     * {@link #isPingSupported(Jid)} is false.
297     *
298     * @param notifyListeners Notify the PingFailedListener in case of error if true
299     * @return true if the user's server could be pinged.
300     * @throws NotConnectedException
301     * @throws InterruptedException 
302     */
303    public boolean pingMyServer(boolean notifyListeners) throws NotConnectedException, InterruptedException {
304        return pingMyServer(notifyListeners, connection().getReplyTimeout());
305    }
306
307    /**
308     * Pings the server. This method will return true if the server is reachable.  It
309     * is the equivalent of calling <code>ping</code> with the XMPP domain.
310     * <p>
311     * Unlike the {@link #ping(Jid)} case, this method will return true even if
312     * {@link #isPingSupported(Jid)} is false.
313     *
314     * @param notifyListeners Notify the PingFailedListener in case of error if true
315     * @param pingTimeout The time to wait for a reply in milliseconds
316     * @return true if the user's server could be pinged.
317     * @throws NotConnectedException
318     * @throws InterruptedException 
319     */
320    public boolean pingMyServer(boolean notifyListeners, long pingTimeout) throws NotConnectedException, InterruptedException {
321        boolean res;
322        try {
323            res = ping(connection().getXMPPServiceDomain(), pingTimeout);
324        }
325        catch (NoResponseException e) {
326            res = false;
327        }
328        if (!res && notifyListeners) {
329            for (PingFailedListener l : pingFailedListeners)
330                l.pingFailed();
331        }
332        return res;
333    }
334
335    /**
336     * Set the interval in seconds between a automated server ping is send. A negative value disables automatic server
337     * pings. All settings take effect immediately. If there is an active scheduled server ping it will be canceled and,
338     * if <code>pingInterval</code> is positive, a new one will be scheduled in pingInterval seconds.
339     * <p>
340     * If the ping fails after 3 attempts waiting the connections reply timeout for an answer, then the ping failed
341     * listeners will be invoked.
342     * </p>
343     *
344     * @param pingInterval the interval in seconds between the automated server pings
345     */
346    public void setPingInterval(int pingInterval) {
347        this.pingInterval = pingInterval;
348        maybeSchedulePingServerTask();
349    }
350
351    /**
352     * Get the current ping interval.
353     *
354     * @return the interval between pings in seconds
355     */
356    public int getPingInterval() {
357        return pingInterval;
358    }
359
360    /**
361     * Register a new PingFailedListener.
362     *
363     * @param listener the listener to invoke
364     */
365    public void registerPingFailedListener(PingFailedListener listener) {
366        pingFailedListeners.add(listener);
367    }
368
369    /**
370     * Unregister a PingFailedListener.
371     *
372     * @param listener the listener to remove
373     */
374    public void unregisterPingFailedListener(PingFailedListener listener) {
375        pingFailedListeners.remove(listener);
376    }
377
378    private void maybeSchedulePingServerTask() {
379        maybeSchedulePingServerTask(0);
380    }
381
382    /**
383     * Cancels any existing periodic ping task if there is one and schedules a new ping task if
384     * pingInterval is greater then zero.
385     *
386     * @param delta the delta to the last received stanza in seconds
387     */
388    private synchronized void maybeSchedulePingServerTask(int delta) {
389        maybeStopPingServerTask();
390        if (pingInterval > 0) {
391            int nextPingIn = pingInterval - delta;
392            LOGGER.fine("Scheduling ServerPingTask in " + nextPingIn + " seconds (pingInterval="
393                            + pingInterval + ", delta=" + delta + ")");
394            nextAutomaticPing = executorService.schedule(pingServerRunnable, nextPingIn, TimeUnit.SECONDS);
395        }
396    }
397
398    private void maybeStopPingServerTask() {
399        if (nextAutomaticPing != null) {
400            nextAutomaticPing.cancel(true);
401            nextAutomaticPing = null;
402        }
403    }
404
405    /**
406     * Ping the server if deemed necessary because automatic server pings are
407     * enabled ({@link #setPingInterval(int)}) and the ping interval has expired.
408     */
409    public synchronized void pingServerIfNecessary() {
410        final int DELTA = 1000; // 1 seconds
411        final int TRIES = 3; // 3 tries
412        final XMPPConnection connection = connection();
413        if (connection == null) {
414            // connection has been collected by GC
415            // which means we can stop the thread by breaking the loop
416            return;
417        }
418        if (pingInterval <= 0) {
419            // Ping has been disabled
420            return;
421        }
422        long lastStanzaReceived = connection.getLastStanzaReceived();
423        if (lastStanzaReceived > 0) {
424            long now = System.currentTimeMillis();
425            // Delta since the last stanza was received
426            int deltaInSeconds = (int)  ((now - lastStanzaReceived) / 1000);
427            // If the delta is small then the ping interval, then we can defer the ping
428            if (deltaInSeconds < pingInterval) {
429                maybeSchedulePingServerTask(deltaInSeconds);
430                return;
431            }
432        }
433        if (connection.isAuthenticated()) {
434            boolean res = false;
435
436            for (int i = 0; i < TRIES; i++) {
437                if (i != 0) {
438                    try {
439                        Thread.sleep(DELTA);
440                    } catch (InterruptedException e) {
441                        // We received an interrupt
442                        // This only happens if we should stop pinging
443                        return;
444                    }
445                }
446                try {
447                    res = pingMyServer(false);
448                }
449                catch (InterruptedException | SmackException e) {
450                    // Note that we log the connection here, so that it is not GC'ed between the call to isAuthenticated
451                    // a few lines above and the usage of the connection within pingMyServer(). In order to prevent:
452                    // https://community.igniterealtime.org/thread/59369
453                    LOGGER.log(Level.WARNING, "Exception while pinging server of " + connection, e);
454                    res = false;
455                }
456                // stop when we receive a pong back
457                if (res) {
458                    break;
459                }
460            }
461            if (!res) {
462                for (PingFailedListener l : pingFailedListeners) {
463                    l.pingFailed();
464                }
465            } else {
466                // Ping was successful, wind-up the periodic task again
467                maybeSchedulePingServerTask();
468            }
469        } else {
470            LOGGER.warning("XMPPConnection was not authenticated");
471        }
472    }
473
474    private final Runnable pingServerRunnable = new Runnable() {
475        @Override
476        public void run() {
477            LOGGER.fine("ServerPingTask run()");
478            pingServerIfNecessary();
479        }
480    };
481
482    @Override
483    protected void finalize() throws Throwable {
484        LOGGER.fine("finalizing PingManager: Shutting down executor service");
485        try {
486            executorService.shutdown();
487        } catch (Throwable t) {
488            LOGGER.log(Level.WARNING, "finalize() threw throwable", t);
489        }
490        finally {
491            super.finalize();
492        }
493    }
494}