001/**
002 *
003 * Copyright 2012-2018 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.ScheduledFuture;
024import java.util.concurrent.TimeUnit;
025import java.util.logging.Level;
026import java.util.logging.Logger;
027
028import org.jivesoftware.smack.AbstractConnectionClosedListener;
029import org.jivesoftware.smack.ConnectionCreationListener;
030import org.jivesoftware.smack.Manager;
031import org.jivesoftware.smack.SmackException;
032import org.jivesoftware.smack.SmackException.NoResponseException;
033import org.jivesoftware.smack.SmackException.NotConnectedException;
034import org.jivesoftware.smack.SmackFuture;
035import org.jivesoftware.smack.SmackFuture.InternalProcessStanzaSmackFuture;
036import org.jivesoftware.smack.XMPPConnection;
037import org.jivesoftware.smack.XMPPConnectionRegistry;
038import org.jivesoftware.smack.XMPPException.XMPPErrorException;
039import org.jivesoftware.smack.iqrequest.AbstractIqRequestHandler;
040import org.jivesoftware.smack.iqrequest.IQRequestHandler.Mode;
041import org.jivesoftware.smack.packet.IQ;
042import org.jivesoftware.smack.packet.IQ.Type;
043import org.jivesoftware.smack.packet.Stanza;
044import org.jivesoftware.smack.packet.StanzaError;
045import org.jivesoftware.smack.util.ExceptionCallback;
046import org.jivesoftware.smack.util.SuccessCallback;
047
048import org.jivesoftware.smackx.disco.ServiceDiscoveryManager;
049import org.jivesoftware.smackx.ping.packet.Ping;
050
051import org.jxmpp.jid.Jid;
052
053/**
054 * Implements the XMPP Ping as defined by XEP-0199. The XMPP Ping protocol allows one entity to
055 * ping any other entity by simply sending a ping to the appropriate JID. PingManger also
056 * periodically sends XMPP pings to the server to avoid NAT timeouts and to test
057 * the connection status.
058 * <p>
059 * The default server ping interval is 30 minutes and can be modified with
060 * {@link #setDefaultPingInterval(int)} and {@link #setPingInterval(int)}.
061 * </p>
062 *
063 * @author Florian Schmaus
064 * @see <a href="http://www.xmpp.org/extensions/xep-0199.html">XEP-0199:XMPP Ping</a>
065 */
066public final class PingManager extends Manager {
067    private static final Logger LOGGER = Logger.getLogger(PingManager.class.getName());
068
069    private static final Map<XMPPConnection, PingManager> INSTANCES = new WeakHashMap<>();
070
071    static {
072        XMPPConnectionRegistry.addConnectionCreationListener(new ConnectionCreationListener() {
073            @Override
074            public void connectionCreated(XMPPConnection connection) {
075                getInstanceFor(connection);
076            }
077        });
078    }
079
080    /**
081     * Retrieves a {@link PingManager} for the specified {@link XMPPConnection}, creating one if it doesn't already
082     * exist.
083     *
084     * @param connection
085     * The connection the manager is attached to.
086     * @return The new or existing manager.
087     */
088    public static synchronized PingManager getInstanceFor(XMPPConnection connection) {
089        PingManager pingManager = INSTANCES.get(connection);
090        if (pingManager == null) {
091            pingManager = new PingManager(connection);
092            INSTANCES.put(connection, pingManager);
093        }
094        return pingManager;
095    }
096
097    /**
098     * The default ping interval in seconds used by new PingManager instances. The default is 30 minutes.
099     */
100    private static int defaultPingInterval = 60 * 30;
101
102    /**
103     * Set the default ping interval which will be used for new connections.
104     *
105     * @param interval the interval in seconds
106     */
107    public static void setDefaultPingInterval(int interval) {
108        defaultPingInterval = interval;
109    }
110
111    private final Set<PingFailedListener> pingFailedListeners = new CopyOnWriteArraySet<>();
112
113    /**
114     * The interval in seconds between pings are send to the users server.
115     */
116    private int pingInterval = defaultPingInterval;
117
118    private ScheduledFuture<?> nextAutomaticPing;
119
120    private PingManager(XMPPConnection connection) {
121        super(connection);
122        ServiceDiscoveryManager sdm = ServiceDiscoveryManager.getInstanceFor(connection);
123        sdm.addFeature(Ping.NAMESPACE);
124
125        connection.registerIQRequestHandler(new AbstractIqRequestHandler(Ping.ELEMENT, Ping.NAMESPACE, Type.get, Mode.async) {
126            @Override
127            public IQ handleIQRequest(IQ iqRequest) {
128                Ping ping = (Ping) iqRequest;
129                return ping.getPong();
130            }
131        });
132        connection.addConnectionListener(new AbstractConnectionClosedListener() {
133            @Override
134            public void authenticated(XMPPConnection connection, boolean resumed) {
135                maybeSchedulePingServerTask();
136            }
137            @Override
138            public void connectionTerminated() {
139                maybeStopPingServerTask();
140            }
141        });
142        maybeSchedulePingServerTask();
143    }
144
145    private boolean isValidErrorPong(Jid destinationJid, XMPPErrorException xmppErrorException) {
146        // If it is an error error response and the destination was our own service, then this must mean that the
147        // service responded, i.e. is up and pingable.
148        if (destinationJid.equals(connection().getXMPPServiceDomain())) {
149            return true;
150        }
151
152        final StanzaError xmppError = xmppErrorException.getStanzaError();
153
154        // We may received an error response from an intermediate service returning an error like
155        // 'remote-server-not-found' or 'remote-server-timeout' to us (which would fake the 'from' address,
156        // see RFC 6120 § 8.3.1 2.). Or the recipient could became unavailable.
157
158        // Sticking with the current rules of RFC 6120/6121, it is undecidable at this point whether we received an
159        // error response from the pinged entity or not. This is because a service-unavailable error condition is
160        // *required* (as per the RFCs) to be send back in both relevant cases:
161        // 1. When the receiving entity is unaware of the IQ request type. RFC 6120 § 8.4.:
162        //    "If an intended recipient receives an IQ stanza of type "get" or
163        //    "set" containing a child element qualified by a namespace it does
164        //    not understand, then the entity MUST return an IQ stanza of type
165        //    "error" with an error condition of <service-unavailable/>.
166        //  2. When the receiving resource is not available. RFC 6121 § 8.5.3.2.3.
167
168        // Some clients don't obey the first rule and instead send back a feature-not-implement condition with type 'cancel',
169        // which allows us to consider this response as valid "error response" pong.
170        StanzaError.Type type = xmppError.getType();
171        StanzaError.Condition condition = xmppError.getCondition();
172        return type == StanzaError.Type.CANCEL && condition == StanzaError.Condition.feature_not_implemented;
173    }
174
175    public SmackFuture<Boolean, Exception> pingAsync(Jid jid) {
176        return pingAsync(jid, connection().getReplyTimeout());
177    }
178
179    public SmackFuture<Boolean, Exception> pingAsync(final Jid jid, long pongTimeout) {
180        final InternalProcessStanzaSmackFuture<Boolean, Exception> future = new InternalProcessStanzaSmackFuture<Boolean, Exception>() {
181            @Override
182            public void handleStanza(Stanza packet) {
183                setResult(true);
184            }
185            @Override
186            public boolean isNonFatalException(Exception exception) {
187                if (exception instanceof XMPPErrorException) {
188                    XMPPErrorException xmppErrorException = (XMPPErrorException) exception;
189                    if (isValidErrorPong(jid, xmppErrorException)) {
190                        setResult(true);
191                        return true;
192                    }
193                }
194                return false;
195            }
196        };
197
198        Ping ping = new Ping(jid);
199        connection().sendIqRequestAsync(ping, pongTimeout)
200        .onSuccess(new SuccessCallback<IQ>() {
201            @Override
202            public void onSuccess(IQ result) {
203                future.processStanza(result);
204            }
205        })
206        .onError(new ExceptionCallback<Exception>() {
207            @Override
208            public void processException(Exception exception) {
209                future.processException(exception);
210            }
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 = 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}